From 6015084fa2502bf4dc941ae39c538f089a0d89b4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 09:34:50 -0400 Subject: [PATCH 0001/1114] Prepare Effect HttpApi backend parity (#24853) --- packages/opencode/scripts/diff-sdk-types.sh | 52 ++ packages/opencode/specs/effect/http-api.md | 8 + packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/auth/index.ts | 3 +- packages/opencode/src/bus/bus-event.ts | 12 + packages/opencode/src/cli/cmd/tui/event.ts | 3 +- packages/opencode/src/config/agent.ts | 4 +- packages/opencode/src/config/console-state.ts | 3 +- packages/opencode/src/config/mcp.ts | 6 +- packages/opencode/src/config/provider.ts | 22 +- packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/ripgrep.ts | 28 +- packages/opencode/src/lsp/lsp.ts | 10 +- packages/opencode/src/project/project.ts | 8 +- packages/opencode/src/project/vcs.ts | 6 +- packages/opencode/src/provider/auth.ts | 4 +- packages/opencode/src/provider/models.ts | 22 +- packages/opencode/src/provider/provider.ts | 18 +- packages/opencode/src/pty/index.ts | 10 +- packages/opencode/src/server/backend.ts | 32 + packages/opencode/src/server/middleware.ts | 23 +- packages/opencode/src/server/proxy.ts | 8 +- .../src/server/routes/instance/httpapi/api.ts | 54 ++ .../server/routes/instance/httpapi/event.ts | 11 +- .../server/routes/instance/httpapi/global.ts | 259 -------- .../instance/httpapi/{ => groups}/config.ts | 44 +- .../instance/httpapi/{ => groups}/control.ts | 46 +- .../httpapi/{ => groups}/experimental.ts | 203 +------ .../instance/httpapi/{ => groups}/file.ts | 78 +-- .../routes/instance/httpapi/groups/global.ts | 106 ++++ .../instance/httpapi/{ => groups}/instance.ts | 97 +-- .../instance/httpapi/{ => groups}/mcp.ts | 115 +--- .../instance/httpapi/groups/metadata.ts | 18 + .../httpapi/{ => groups}/permission.ts | 44 +- .../instance/httpapi/{ => groups}/project.ts | 66 +- .../instance/httpapi/groups/provider.ts | 74 +++ .../routes/instance/httpapi/groups/pty.ts | 121 ++++ .../instance/httpapi/{ => groups}/question.ts | 52 +- .../routes/instance/httpapi/groups/session.ts | 428 +++++++++++++ .../routes/instance/httpapi/groups/sync.ts | 90 +++ .../routes/instance/httpapi/groups/tui.ts | 164 +++++ .../instance/httpapi/groups/workspace.ts | 103 ++++ .../instance/httpapi/handlers/config.ts | 34 ++ .../instance/httpapi/handlers/control.ts | 34 ++ .../instance/httpapi/handlers/experimental.ts | 155 +++++ .../routes/instance/httpapi/handlers/file.ts | 54 ++ .../instance/httpapi/handlers/global.ts | 156 +++++ .../instance/httpapi/handlers/instance.ts | 79 +++ .../routes/instance/httpapi/handlers/mcp.ts | 68 +++ .../instance/httpapi/handlers/permission.ts | 29 + .../instance/httpapi/handlers/project.ts | 46 ++ .../instance/httpapi/handlers/provider.ts | 89 +++ .../routes/instance/httpapi/handlers/pty.ts | 118 ++++ .../instance/httpapi/handlers/question.ts | 33 + .../httpapi/{ => handlers}/session.ts | 491 ++------------- .../routes/instance/httpapi/handlers/sync.ts | 54 ++ .../routes/instance/httpapi/handlers/tui.ts | 134 +++++ .../instance/httpapi/handlers/workspace.ts | 66 ++ .../instance/httpapi/instance-context.ts | 191 ++++++ .../routes/instance/httpapi/lifecycle.ts | 17 +- .../routes/instance/httpapi/provider.ts | 157 ----- .../src/server/routes/instance/httpapi/pty.ts | 242 -------- .../server/routes/instance/httpapi/public.ts | 448 +++++++++++--- .../server/routes/instance/httpapi/server.ts | 200 +++---- .../server/routes/instance/httpapi/sync.ts | 137 ----- .../src/server/routes/instance/httpapi/tui.ts | 291 --------- .../routes/instance/httpapi/workspace.ts | 166 ----- packages/opencode/src/server/server.ts | 41 +- packages/opencode/src/server/workspace.ts | 23 +- packages/opencode/src/session/message-v2.ts | 104 ++-- packages/opencode/src/session/message.ts | 28 +- packages/opencode/src/session/session.ts | 28 +- packages/opencode/src/session/status.ts | 6 +- packages/opencode/src/snapshot/index.ts | 6 +- packages/opencode/src/storage/storage.ts | 5 +- packages/opencode/src/sync/index.ts | 16 + packages/opencode/src/tool/bash.ts | 3 +- packages/opencode/src/tool/codesearch.ts | 2 +- packages/opencode/src/tool/lsp.ts | 12 +- packages/opencode/src/tool/read.ts | 5 +- .../opencode/src/util/named-schema-error.ts | 7 + packages/opencode/src/util/schema.ts | 2 + packages/opencode/src/v2/session-entry.ts | 15 +- packages/opencode/src/v2/session-event.ts | 22 +- .../test/server/httpapi-bridge.test.ts | 50 +- .../test/server/httpapi-experimental.test.ts | 2 +- .../opencode/test/server/httpapi-file.test.ts | 2 +- .../test/server/httpapi-instance.test.ts | 2 +- .../test/server/httpapi-json-parity.test.ts | 6 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 +- .../test/server/httpapi-provider.test.ts | 2 +- .../opencode/test/server/httpapi-pty.test.ts | 2 +- .../opencode/test/server/httpapi-sdk.test.ts | 566 +++++++++++++++++- .../test/server/httpapi-session.test.ts | 2 +- .../opencode/test/server/httpapi-sync.test.ts | 4 +- .../opencode/test/server/httpapi-tui.test.ts | 2 +- .../test/server/httpapi-workspace.test.ts | 116 +++- .../__snapshots__/parameters.test.ts.snap | 15 +- 98 files changed, 4294 insertions(+), 2770 deletions(-) create mode 100755 packages/opencode/scripts/diff-sdk-types.sh create mode 100644 packages/opencode/src/server/backend.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/api.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/global.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/config.ts (54%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/control.ts (59%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/experimental.ts (51%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/file.ts (58%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/global.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/instance.ts (58%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/mcp.ts (51%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/permission.ts (57%) rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/project.ts (50%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => groups}/question.ts (58%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts rename packages/opencode/src/server/routes/instance/httpapi/{ => handlers}/session.ts (51%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/instance-context.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/provider.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/pty.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/sync.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/tui.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/workspace.ts diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh new file mode 100755 index 000000000000..b27a31e8c3a5 --- /dev/null +++ b/packages/opencode/scripts/diff-sdk-types.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Compare SDK types generated from Hono vs HttpApi specs. +# Sorts types alphabetically so only meaningful body differences show. +# +# Usage: ./scripts/diff-sdk-types.sh # full diff +# ./scripts/diff-sdk-types.sh --stat # summary only +set -euo pipefail + +DIR="$(cd "$(dirname "$0")/.." && pwd)" +SDK="$(cd "$DIR/../sdk/js" && pwd)" + +normalize() { + python3 -c " +import re, sys +content = open(sys.argv[1]).read() +blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) +header, body = blocks[0], blocks[1:] +body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') +sys.stdout.write(header + ''.join(body)) +" "$1" +} + +echo "Generating Hono SDK..." >&2 +(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "Generating HttpApi SDK..." >&2 +(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) +normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts +git -C "$SDK" checkout -- src/ 2>/dev/null + +echo "" >&2 +if [[ "${1:-}" == "--stat" ]]; then + diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true) + honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true) + aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true) + total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ') + echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" + echo "" + if [[ $honly -gt 0 ]]; then + echo "=== Hono-only types ===" + printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /' + echo "" + fi + if [[ $aonly -gt 0 ]]; then + echo "=== HttpApi-only types ===" + printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /' + fi +else + diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true +fi diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 791aa0e28f79..6d6602e9466b 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -129,6 +129,14 @@ Required before route deletion: - Compare generated SDK output against `dev` for every route group deletion. - Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. +V2 cleanup once SDK compatibility no longer needs the legacy Hono contract: + +- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses. +- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape. +- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks. +- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape. +- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec. + ### 5. Make HttpApi Default For JSON Routes After JSON parity and SDK generation are covered: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5e839ead5c88..81dbded08202 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -31,8 +31,8 @@ export const Info = Schema.Struct({ mode: Schema.Literals(["subagent", "primary", "all"]), native: Schema.optional(Schema.Boolean), hidden: Schema.optional(Schema.Boolean), - topP: Schema.optional(Schema.Number), - temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Finite), + temperature: Schema.optional(Schema.Finite), color: Schema.optional(Schema.String), permission: Permission.Ruleset, model: Schema.optional( @@ -44,7 +44,7 @@ export const Info = Schema.Struct({ variant: Schema.optional(Schema.String), prompt: Schema.optional(Schema.String), options: Schema.Record(Schema.String, Schema.Unknown), - steps: Schema.optional(Schema.Number), + steps: Schema.optional(Schema.Finite), }) .annotate({ identifier: "Agent" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 539c40c1ae63..3d6a0d91d0d1 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -14,7 +15,7 @@ export class Oauth extends Schema.Class("OAuth")({ type: Schema.Literal("oauth"), refresh: Schema.String, access: Schema.String, - expires: Schema.Number, + expires: NonNegativeInt, accountId: Schema.optional(Schema.String), enterpriseUrl: Schema.optional(Schema.String), }) {} diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index f27d26335443..cf9fcfbeec8c 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -34,4 +34,16 @@ export function payloads() { .toArray() } +export function effectPayloads() { + return registry + .entries() + .map(([type, def]) => + Schema.Struct({ + type: Schema.Literal(type), + properties: def.properties, + }).annotate({ identifier: `Event.${type}` }), + ) + .toArray() +} + export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 1c764c12fef2..fbe5ce7f9ffd 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" +import { PositiveInt } from "@/util/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 @@ -38,7 +39,7 @@ export const TuiEvent = { title: Schema.optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), - duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ + duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ description: "Duration in milliseconds", }), }), diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e673edbad4c9..e72f6587284a 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest( variant: Schema.optional(Schema.String).annotate({ description: "Default model variant for this agent (applies only when using the agent's configured model).", }), - temperature: Schema.optional(Schema.Number), - top_p: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Finite), + top_p: Schema.optional(Schema.Finite), prompt: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({ description: "@deprecated Use 'permission' field instead", diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 08668afe4e8f..0d4f20df9171 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,10 +1,11 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { NonNegativeInt } from "@/util/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optional(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: NonNegativeInt, }) { static readonly zod = zod(this) } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 0887fa984ab7..fc31ba356fa8 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { PositiveInt, withStatics } from "@/util/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), @@ -13,7 +13,7 @@ export const Local = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable the MCP server on startup", }), - timeout: Schema.optional(Schema.Number).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) @@ -49,7 +49,7 @@ export const Remote = Schema.Struct({ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({ description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", }), - timeout: Schema.optional(Schema.Number).annotate({ + timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), }) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index cd7469435cb7..7821bca5a937 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -21,25 +21,25 @@ export const Model = Schema.Struct({ ), cost: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), }), ), }), ), limit: Schema.optional( Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }), ), modalities: Schema.optional( diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 122add21fa88..4a474881cb9f 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -15,12 +15,12 @@ import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" import { zod } from "@/util/effect-zod" -import { type DeepMutable, withStatics } from "@/util/schema" +import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" export const Info = Schema.Struct({ path: Schema.String, - added: Schema.Int, - removed: Schema.Int, + added: NonNegativeInt, + removed: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "File" }) @@ -39,10 +39,10 @@ export const Node = Schema.Struct({ export type Node = DeepMutable> const Hunk = Schema.Struct({ - oldStart: Schema.Number, - oldLines: Schema.Number, - newStart: Schema.Number, - newLines: Schema.Number, + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, lines: Schema.Array(Schema.String), }) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 3a5411c31eab..27fd5f2323d6 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" @@ -27,19 +27,19 @@ const PLATFORM = { } as const const TimeStats = Schema.Struct({ - secs: Schema.Number, - nanos: Schema.Number, + secs: NonNegativeInt, + nanos: NonNegativeInt, human: Schema.String, }) const Stats = Schema.Struct({ elapsed: TimeStats, - searches: Schema.Number, - searches_with_match: Schema.Number, - bytes_searched: Schema.Number, - bytes_printed: Schema.Number, - matched_lines: Schema.Number, - matches: Schema.Number, + searches: NonNegativeInt, + searches_with_match: NonNegativeInt, + bytes_searched: NonNegativeInt, + bytes_printed: NonNegativeInt, + matched_lines: NonNegativeInt, + matches: NonNegativeInt, }) const PathText = Schema.Struct({ @@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({ lines: Schema.Struct({ text: Schema.String, }), - line_number: Schema.Number, - absolute_offset: Schema.Number, + line_number: NonNegativeInt, + absolute_offset: NonNegativeInt, submatches: Schema.Array( Schema.Struct({ match: Schema.Struct({ text: Schema.String, }), - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -80,7 +80,7 @@ const End = Schema.Struct({ type: Schema.Literal("end"), data: Schema.Struct({ path: PathText, - binary_offset: Schema.NullOr(Schema.Number), + binary_offset: Schema.NullOr(NonNegativeInt), stats: Stats, }), }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 45a81899766c..5fcff772ec24 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,7 +13,7 @@ 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 { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" const log = Log.create({ service: "lsp" }) @@ -23,8 +23,8 @@ export const Event = { } const Position = Schema.Struct({ - line: Schema.Number, - character: Schema.Number, + line: NonNegativeInt, + character: NonNegativeInt, }) export const Range = Schema.Struct({ @@ -37,7 +37,7 @@ export type Range = typeof Range.Type export const Symbol = Schema.Struct({ name: Schema.String, - kind: Schema.Number, + kind: NonNegativeInt, location: Schema.Struct({ uri: Schema.String, range: Range, @@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type export const DocumentSymbol = Schema.Struct({ name: Schema.String, detail: Schema.optional(Schema.String), - kind: Schema.Number, + kind: NonNegativeInt, range: Range, selectionRange: Range, }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 648bfc8fed75..4229112a838b 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "project" }) @@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({ }) const ProjectTime = Schema.Struct({ - created: Schema.Number, - updated: Schema.Number, - initialized: Schema.optional(Schema.Number), + created: NonNegativeInt, + updated: NonNegativeInt, + initialized: Schema.optional(NonNegativeInt), }) export const Info = Schema.Struct({ diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e12a031d63b2..24112cf4422d 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) @@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 4df83f020421..6cbfcf1be2bf 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -58,13 +58,13 @@ export class Authorization extends Schema.Class("ProviderAuthAuth } export const AuthorizeInput = Schema.Struct({ - method: Schema.Number.annotate({ description: "Auth method index" }), + method: Schema.Finite.annotate({ description: "Auth method index" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ - method: Schema.Number.annotate({ description: "Auth method index" }), + method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type CallbackInput = Schema.Schema.Type diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index ed2d11eb72d6..170fe516c97c 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,16 +22,16 @@ const filepath = path.join( const ttl = 5 * 60 * 1000 const Cost = Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - cache_read: Schema.optional(Schema.Number), - cache_write: Schema.optional(Schema.Number), + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), }), ), }) @@ -55,9 +55,9 @@ export const Model = Schema.Struct({ ), cost: Schema.optional(Cost), limit: Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }), modalities: Schema.optional( Schema.Struct({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c05d05319353..48df5a4c9d0b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({ }) const ProviderCacheCost = Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: Schema.Finite, + write: Schema.Finite, }) const ProviderCost = Schema.Struct({ - input: Schema.Number, - output: Schema.Number, + input: Schema.Finite, + output: Schema.Finite, cache: ProviderCacheCost, experimentalOver200K: Schema.optional( Schema.Struct({ - input: Schema.Number, - output: Schema.Number, + input: Schema.Finite, + output: Schema.Finite, cache: ProviderCacheCost, }), ), }) const ProviderLimit = Schema.Struct({ - context: Schema.Number, - input: Schema.optional(Schema.Number), - output: Schema.Number, + context: Schema.Finite, + input: Schema.optional(Schema.Finite), + output: Schema.Finite, }) export const Model = Schema.Struct({ diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index beccade09b22..2518800ce811 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" const log = Log.create({ service: "pty" }) @@ -62,7 +62,7 @@ export const Info = Schema.Struct({ args: Schema.Array(Schema.String), cwd: Schema.String, status: Schema.Literals(["running", "exited"]), - pid: Schema.Number, + pid: PositiveInt, }) .annotate({ identifier: "Pty" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), size: Schema.optional( Schema.Struct({ - rows: Schema.Number, - cols: Schema.Number, + rows: PositiveInt, + cols: PositiveInt, }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable + +export function select(): Selection { + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" } + return { backend: "hono", reason: "stable" } +} + +export function attributes(selection: Selection): Record { + return { + "opencode.server.backend": selection.backend, + "opencode.server.backend.reason": selection.reason, + "opencode.installation.channel": InstallationChannel, + "opencode.installation.version": InstallationVersion, + } +} + +export function force(selection: Selection, backend: Backend): Selection { + return { + backend, + reason: selection.backend === backend ? selection.reason : "explicit", + } +} diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index ffcfd4ce0159..c653156d33a7 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" +import * as ServerBackend from "./backend" const log = Log.create({ service: "server" }) @@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { return basicAuth({ username, password })(c, next) } -export const LoggerMiddleware: MiddlewareHandler = async (c, next) => { - const skip = c.req.path === "/log" - if (!skip) { - log.info("request", { +export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler { + return async (c, next) => { + const skip = c.req.path === "/log" + if (skip) return next() + const attributes = { method: c.req.method, path: c.req.path, - }) + ...backendAttributes, + } + log.info("request", attributes) + const timer = log.time("request", attributes) + await next() + timer.stop() } - const timer = log.time("request", { - method: c.req.method, - path: c.req.path, - }) - await next() - if (!skip) timer.stop() } export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 441d7a5c2d59..f93150020d09 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) { return out } -function protocols(req: Request) { +export function websocketProtocols(req: Request) { const value = req.headers.get("sec-websocket-protocol") if (!value) return [] return value @@ -42,7 +42,7 @@ function protocols(req: Request) { .filter(Boolean) } -function socket(url: string | URL) { +export function websocketTargetURL(url: string | URL) { const next = new URL(url) if (next.protocol === "http:") next.protocol = "ws:" if (next.protocol === "https:") next.protocol = "wss:" @@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) => ws.close(1011, "missing proxy target") return } - remote = new WebSocket(url, protocols(c.req.raw)) + remote = new WebSocket(url, websocketProtocols(c.req.raw)) remote.binaryType = "arraybuffer" remote.onopen = () => { for (const item of queue) remote?.send(item) @@ -150,7 +150,7 @@ export function websocket( proxy.pathname = "/__workspace_ws" proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", socket(target)) + next.set("x-opencode-proxy-url", websocketTargetURL(target)) for (const [key, value] of new Headers(extra).entries()) { next.set(key, value) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts new file mode 100644 index 000000000000..81ea2394c061 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import { HttpApi } from "effect/unstable/httpapi" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { ConfigApi } from "./groups/config" +import { ControlApi } from "./groups/control" +import { EventApi } from "./event" +import { ExperimentalApi } from "./groups/experimental" +import { FileApi } from "./groups/file" +import { GlobalApi } from "./groups/global" +import { InstanceApi } from "./groups/instance" +import { McpApi } from "./groups/mcp" +import { PermissionApi } from "./groups/permission" +import { ProjectApi } from "./groups/project" +import { ProviderApi } from "./groups/provider" +import { PtyApi, PtyConnectApi } from "./groups/pty" +import { QuestionApi } from "./groups/question" +import { SessionApi } from "./groups/session" +import { SyncApi } from "./groups/sync" +import { TuiApi } from "./groups/tui" +import { WorkspaceApi } from "./groups/workspace" + +// SSE event schemas built from the same BusEvent/SyncEvent registries that +// the Hono spec uses, so both specs emit identical Event/SyncEvent components. +const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) +const SyncEventSchemas = SyncEvent.effectPayloads() + +export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) + +export const InstanceHttpApi = HttpApi.make("opencode-instance") + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(FileApi) + .addHttpApi(InstanceApi) + .addHttpApi(McpApi) + .addHttpApi(ProjectApi) + .addHttpApi(PtyApi) + .addHttpApi(QuestionApi) + .addHttpApi(PermissionApi) + .addHttpApi(ProviderApi) + .addHttpApi(SessionApi) + .addHttpApi(SyncApi) + .addHttpApi(TuiApi) + .addHttpApi(WorkspaceApi) + +export const OpenCodeHttpApi = HttpApi.make("opencode") + .addHttpApi(RootHttpApi) + .addHttpApi(EventApi) + .addHttpApi(InstanceHttpApi) + .addHttpApi(PtyConnectApi) + .annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas]) + +export type RootHttpApiType = typeof RootHttpApi +export type InstanceHttpApiType = typeof InstanceHttpApi diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 1d548e0bafd4..9f4ddde4c268 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -4,6 +4,7 @@ import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" const log = Log.create({ service: "server" }) @@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add( .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), ) -function eventData(data: unknown) { - return `data: ${JSON.stringify(data)}\n\n` +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } } export const eventRoute = HttpRouter.add( @@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add( Stream.make({ type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), Stream.encodeText, Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), ), diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts deleted file mode 100644 index ef7fb331f6cb..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/global.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Config } from "@/config/config" -import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" -import { Installation } from "@/installation" -import { Instance } from "@/project/instance" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Schema } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -const log = Log.create({ service: "server" }) - -const GlobalHealth = Schema.Struct({ - healthy: Schema.Literal(true), - version: Schema.String, -}).annotate({ identifier: "GlobalHealth" }) - -const GlobalEventSchema = Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Unknown, -}).annotate({ identifier: "GlobalEvent" }) - -const GlobalUpgradeInput = Schema.Struct({ - target: Schema.optional(Schema.String), -}).annotate({ identifier: "GlobalUpgradeInput" }) - -const GlobalUpgradeResult = Schema.Union([ - Schema.Struct({ - success: Schema.Literal(true), - version: Schema.String, - }), - Schema.Struct({ - success: Schema.Literal(false), - error: Schema.String, - }), -]).annotate({ identifier: "GlobalUpgradeResult" }) - -export const GlobalPaths = { - health: "/global/health", - event: "/global/event", - config: "/global/config", - dispose: "/global/dispose", - upgrade: "/global/upgrade", -} as const - -export const GlobalApi = HttpApi.make("global").add( - HttpApiGroup.make("global") - .add( - HttpApiEndpoint.get("health", GlobalPaths.health, { - success: GlobalHealth, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.health", - summary: "Get health", - description: "Get health information about the OpenCode server.", - }), - ), - HttpApiEndpoint.get("event", GlobalPaths.event, { - success: GlobalEventSchema, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.event", - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - }), - ), - HttpApiEndpoint.get("configGet", GlobalPaths.config, { - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.get", - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { - payload: Config.Info, - success: Config.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.config.update", - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - }), - ), - HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.dispose", - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - }), - ), - HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { - payload: GlobalUpgradeInput, - success: GlobalUpgradeResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "global.upgrade", - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), -) - -function eventData(data: unknown) { - return `data: ${JSON.stringify(data)}\n\n` -} - -function parseBody(body: string) { - try { - return JSON.parse(body || "{}") as unknown - } catch { - return undefined - } -} - -function eventResponse() { - const encoder = new TextEncoder() - let heartbeat: ReturnType | undefined - let unsubscribe = () => {} - let done = false - - const cleanup = () => { - if (done) return - done = true - if (heartbeat) clearInterval(heartbeat) - unsubscribe() - log.info("global event disconnected") - } - - log.info("global event connected") - return HttpServerResponse.raw( - new Response( - new ReadableStream({ - start(controller) { - const write = (data: unknown) => { - if (done) return - try { - controller.enqueue(encoder.encode(eventData(data))) - } catch { - cleanup() - } - } - const handler = (event: GlobalBusEvent) => write(event) - unsubscribe = () => GlobalBus.off("event", handler) - GlobalBus.on("event", handler) - write({ payload: { type: "server.connected", properties: {} } }) - heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000) - }, - cancel: cleanup, - }), - { - headers: { - "Cache-Control": "no-cache, no-transform", - "Content-Type": "text/event-stream", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ), - ) -} - -export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) => - Effect.gen(function* () { - const config = yield* Config.Service - const installation = yield* Installation.Service - - const health = Effect.fn("GlobalHttpApi.health")(function* () { - return { healthy: true as const, version: InstallationVersion } - }) - - const event = Effect.fn("GlobalHttpApi.event")(function* () { - return eventResponse() - }) - - const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { - return yield* config.getGlobal() - }) - - const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) - }) - - const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) - return true - }) - - const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { - const method = yield* installation.method() - if (method === "unknown") { - return { - status: 400, - body: { success: false as const, error: "Unknown installation method" }, - } - } - const target = ctx.payload.target || (yield* installation.latest(method)) - const result = yield* installation.upgrade(method, target).pipe( - Effect.as({ status: 200, body: { success: true as const, version: target } }), - Effect.catch((err) => - Effect.succeed({ - status: 500, - body: { - success: false as const, - error: err instanceof Error ? err.message : String(err), - }, - }), - ), - ) - if (!result.body.success) return result - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return result - }) - - const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const json = parseBody(body) - if (json === undefined) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( - Effect.map((payload) => ({ valid: true as const, payload })), - Effect.catch(() => Effect.succeed({ valid: false as const })), - ) - if (!payload.valid) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const result = yield* upgrade({ payload: payload.payload }) - return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) - }) - - return handlers - .handle("health", health) - .handleRaw("event", event) - .handle("configGet", configGet) - .handle("configUpdate", configUpdate) - .handle("dispose", dispose) - .handleRaw("upgrade", upgradeRaw) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts similarity index 54% rename from packages/opencode/src/server/routes/instance/httpapi/config.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index eef825967bdd..4ff406e2a416 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,10 +1,9 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" -import * as InstanceState from "@/effect/instance-state" -import { Effect } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/config" @@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { - success: Config.Info, + success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ identifier: "config.get", @@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config") ), HttpApiEndpoint.patch("update", root, { payload: Config.Info, - success: Config.Info, + success: described(Config.Info, "Successfully updated config"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "config.update", @@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { - success: Provider.ConfigProvidersResult, + success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ identifier: "config.providers", @@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi config routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) => - Effect.gen(function* () { - const providerSvc = yield* Provider.Service - const configSvc = yield* Config.Service - - const get = Effect.fn("ConfigHttpApi.get")(function* () { - return yield* configSvc.get() - }) - - const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) - yield* markInstanceForDisposal(yield* InstanceState.context) - return ctx.payload - }) - - const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* providerSvc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }) - - return handlers.handle("get", get).handle("update", update).handle("providers", providers) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts similarity index 59% rename from packages/opencode/src/server/routes/instance/httpapi/control.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/control.ts index 718629db7172..33e6a8e4a05b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control.ts @@ -1,8 +1,8 @@ import { Auth } from "@/auth" import { ProviderID } from "@/provider/schema" -import * as Log from "@opencode-ai/core/util/log" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { described } from "./metadata" const AuthParams = Schema.Struct({ providerID: ProviderID, @@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({ workspace: Schema.optional(Schema.String), }) -const LogInput = Schema.Struct({ +export const LogInput = Schema.Struct({ service: Schema.String.annotate({ description: "Service name for the log entry" }), level: Schema.Union([ Schema.Literal("debug"), @@ -25,7 +25,7 @@ const LogInput = Schema.Struct({ extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "Additional metadata for the log entry", }), -}).annotate({ identifier: "AppLogInput" }) +}) export const ControlPaths = { auth: "/auth/:providerID", @@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add( HttpApiEndpoint.put("authSet", ControlPaths.auth, { params: AuthParams, payload: Auth.Info, - success: Schema.Boolean, + success: described(Schema.Boolean, "Successfully set authentication credentials"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "auth.set", @@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add( ), HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { params: AuthParams, - success: Schema.Boolean, + success: described(Schema.Boolean, "Successfully removed authentication credentials"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "auth.remove", @@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add( HttpApiEndpoint.post("log", ControlPaths.log, { query: LogQuery, payload: LogInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Log entry written successfully"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "app.log", @@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add( ) .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), ) - -export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) => - Effect.gen(function* () { - const auth = yield* Auth.Service - - const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { - params: { providerID: ProviderID } - payload: Auth.Info - }) { - yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) - return true - }) - - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { - yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) - return true - }) - - const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { - const logger = Log.create({ service: ctx.payload.service }) - logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) - return true - }) - - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/experimental.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index cc39c7604ba0..2a562b46b3a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -1,24 +1,19 @@ -import { Account } from "@/account/account" import { AccountID, OrgID } from "@/account/schema" -import { Agent } from "@/agent/agent" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" -import { Project } from "@/project/project" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" -import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" import { Worktree } from "@/worktree" -import { Effect, Option, Schema, SchemaGetter } from "effect" -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { NonNegativeInt } from "@/util/schema" +import { Schema, SchemaGetter } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optionalKey(Schema.String), - switchableOrgCount: Schema.Number, + switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) const ConsoleOrgOption = Schema.Struct({ @@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({ orgID: Schema.String, orgName: Schema.String, active: Schema.Boolean, -}).annotate({ identifier: "ConsoleOrgOption" }) +}) const ConsoleOrgList = Schema.Struct({ orgs: Schema.Array(ConsoleOrgOption), -}).annotate({ identifier: "ConsoleOrgList" }) +}) -const ConsoleSwitchPayload = Schema.Struct({ +export const ConsoleSwitchPayload = Schema.Struct({ accountID: AccountID, orgID: OrgID, -}).annotate({ identifier: "ConsoleSwitchInput" }) +}) const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" }) const ToolListItem = Schema.Struct({ id: Schema.String, description: Schema.String, - parameters: Schema.Record(Schema.String, Schema.Any), + parameters: Schema.Unknown, }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) -const ToolListQuery = Schema.Struct({ +export const ToolListQuery = Schema.Struct({ provider: ProviderID, model: ModelID, }) @@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" }) -const SessionListQuery = Schema.Struct({ +const WorktreeList = Schema.Array(Schema.String) +export const SessionListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), @@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { - success: ConsoleStateResponse, + success: described(ConsoleStateResponse, "Active Console provider metadata"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.get", @@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { - success: ConsoleOrgList, + success: described(ConsoleOrgList, "Switchable Console orgs"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.listOrgs", @@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { payload: ConsoleSwitchPayload, - success: Schema.Boolean, + success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ @@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("tool", ExperimentalPaths.tool, { query: ToolListQuery, - success: ToolList, + success: described(ToolList, "Tools"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tool.list", @@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { - success: ToolIDs, + success: described(ToolIDs, "Tool IDs"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "tool.ids", @@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { - success: WorktreeList, + success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ identifier: "worktree.list", @@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { payload: Schema.optional(Worktree.CreateInput), - success: Worktree.Info, + success: described(Worktree.Info, "Worktree created"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.create", @@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { payload: Worktree.RemoveInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Worktree removed"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.remove", @@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { payload: Worktree.ResetInput, - success: Schema.Boolean, + success: described(Schema.Boolean, "Worktree reset"), + error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ identifier: "worktree.reset", @@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("session", ExperimentalPaths.session, { query: SessionListQuery, - success: Schema.Array(Session.GlobalInfo), + success: described(Schema.Array(Session.GlobalInfo), "List of sessions"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.session.list", @@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { - success: Schema.Record(Schema.String, MCP.Resource), + success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ identifier: "experimental.resource.list", @@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi read-only routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) => - Effect.gen(function* () { - const account = yield* Account.Service - const agents = yield* Agent.Service - const config = yield* Config.Service - const mcp = yield* MCP.Service - const project = yield* Project.Service - const registry = yield* ToolRegistry.Service - const worktreeSvc = yield* Worktree.Service - - const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { - const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - return { - consoleManagedProviders: state.consoleManagedProviders, - ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }) - - const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { - const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - const info = Option.getOrUndefined(active) - return { - orgs: groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ), - } - }) - - const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { - payload: typeof ConsoleSwitchPayload.Type - }) { - yield* account - .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { - const list = yield* registry.tools({ - providerID: ctx.query.provider, - modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - return list.map((item) => ({ - id: item.id, - description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), - })) - }) - - const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { - return yield* registry.ids() - }) - - const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { - const ctx = yield* InstanceState.context - return yield* project.sandboxes(ctx.project.id) - }) - - const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { - payload: Worktree.CreateInput | undefined - }) { - return yield* worktreeSvc.create(ctx.payload) - }) - - const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { - payload: Worktree.RemoveInput - }) { - const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) - yield* project.removeSandbox(ctx.project.id, input.payload.directory) - return true - }) - - const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { - payload: Worktree.ResetInput - }) { - yield* worktreeSvc.reset(ctx.payload) - return true - }) - - const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { - const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions - return HttpServerResponse.jsonUnsafe(list, { - headers: - sessions.length > limit && list.length > 0 - ? { "x-next-cursor": String(list[list.length - 1].time.updated) } - : undefined, - }) - }) - - const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { - return yield* mcp.resources() - }) - - return handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - .handle("session", session) - .handle("resource", resource) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/file.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index df525680ae28..3a4f3df7f9c9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -1,20 +1,21 @@ import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" -import * as InstanceState from "@/effect/instance-state" import { LSP } from "@/lsp/lsp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" -const FileQuery = Schema.Struct({ +export const FileQuery = Schema.Struct({ path: Schema.String, }) -const FindTextQuery = Schema.Struct({ +export const FindTextQuery = Schema.Struct({ pattern: Schema.String, }) -const FindFileQuery = Schema.Struct({ +export const FindFileQuery = Schema.Struct({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -23,7 +24,7 @@ const FindFileQuery = Schema.Struct({ ), }) -const FindSymbolQuery = Schema.Struct({ +export const FindSymbolQuery = Schema.Struct({ query: Schema.String, }) @@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file") .add( HttpApiEndpoint.get("findText", FilePaths.findText, { query: FindTextQuery, - success: Schema.Array(Ripgrep.SearchMatch), + success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"), }).annotateMerge( OpenApi.annotations({ identifier: "find.text", @@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("findFile", FilePaths.findFile, { query: FindFileQuery, - success: Schema.Array(Schema.String), + success: described(Schema.Array(Schema.String), "File paths"), }).annotateMerge( OpenApi.annotations({ identifier: "find.files", @@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, { query: FindSymbolQuery, - success: Schema.Array(LSP.Symbol), + success: described(Schema.Array(LSP.Symbol), "Symbols"), }).annotateMerge( OpenApi.annotations({ identifier: "find.symbols", @@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("list", FilePaths.list, { query: FileQuery, - success: Schema.Array(File.Node), + success: described(Schema.Array(File.Node), "Files and directories"), }).annotateMerge( OpenApi.annotations({ identifier: "file.list", @@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file") ), HttpApiEndpoint.get("content", FilePaths.content, { query: FileQuery, - success: File.Content, + success: described(File.Content, "File content"), }).annotateMerge( OpenApi.annotations({ identifier: "file.read", @@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { - success: Schema.Array(File.Info), + success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ identifier: "file.status", @@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi file routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) => - Effect.gen(function* () { - const svc = yield* File.Service - const ripgrep = yield* Ripgrep.Service - - const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { - return (yield* ripgrep - .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) - .pipe(Effect.orDie)).items - }) - - const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { - query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } - }) { - return yield* svc.search({ - query: ctx.query.query, - limit: ctx.query.limit ?? 10, - dirs: ctx.query.dirs !== "false", - type: ctx.query.type, - }) - }) - - const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { - return [] - }) - - const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { - return yield* svc.list(ctx.query.path) - }) - - const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { - return yield* svc.read(ctx.query.path) - }) - - const status = Effect.fn("FileHttpApi.status")(function* () { - return yield* svc.status() - }) - - return handlers - .handle("findText", findText) - .handle("findFile", findFile) - .handle("findSymbol", findSymbol) - .handle("list", list) - .handle("content", content) - .handle("status", status) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts new file mode 100644 index 000000000000..272b086065b8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -0,0 +1,106 @@ +import { Config } from "@/config/config" +import { BusEvent } from "@/bus/bus-event" +import { SyncEvent } from "@/sync" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { described } from "./metadata" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}) + +const GlobalEventSchema = Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]), +}).annotate({ identifier: "GlobalEvent" }) + +export const GlobalUpgradeInput = Schema.Struct({ + target: Schema.optional(Schema.String), +}) + +const GlobalUpgradeResult = Schema.Union([ + Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, + }), + Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, + }), +]) + +export const GlobalPaths = { + health: "/global/health", + event: "/global/event", + config: "/global/config", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global").add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: described(GlobalHealth, "Health information"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEventSchema, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: described(Config.Info, "Get global config info"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: described(Config.Info, "Successfully updated global config"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: described(Schema.Boolean, "Global disposed"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: described(GlobalUpgradeResult, "Upgrade result"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/instance.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 8c471c12a0dd..cc450f448c27 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -1,15 +1,14 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { Format } from "@/format" -import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" -import * as InstanceState from "@/effect/instance-state" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForDisposal } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const PathInfo = Schema.Struct({ home: Schema.String, @@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({ directory: Schema.String, }).annotate({ identifier: "Path" }) -const VcsDiffQuery = Schema.Struct({ +export const VcsDiffQuery = Schema.Struct({ mode: Vcs.Mode, }) @@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { - success: Schema.Boolean, + success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ identifier: "instance.dispose", @@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { - success: Vcs.Info, + success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ identifier: "vcs.get", @@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance") ), HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, { query: VcsDiffQuery, - success: Schema.Array(Vcs.FileDiff), + success: described(Schema.Array(Vcs.FileDiff), "VCS diff"), }).annotateMerge( OpenApi.annotations({ identifier: "vcs.diff", @@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { - success: Schema.Array(Command.Info), + success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ identifier: "command.list", @@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { - success: Schema.Array(Agent.Info), + success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ identifier: "app.agents", @@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { - success: Schema.Array(Skill.Info), + success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ identifier: "app.skills", @@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { - success: Schema.Array(LSP.Status), + success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ identifier: "lsp.status", @@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { - success: Schema.Array(Format.Status), + success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ identifier: "formatter.status", @@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi instance read routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) => - Effect.gen(function* () { - const agent = yield* Agent.Service - const command = yield* Command.Service - const format = yield* Format.Service - const lsp = yield* LSP.Service - const skill = yield* Skill.Service - const vcs = yield* Vcs.Service - - const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { - yield* markInstanceForDisposal(yield* InstanceState.context) - return true - }) - - const getPath = Effect.fn("InstanceHttpApi.path")(function* () { - const ctx = yield* InstanceState.context - return { - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: ctx.worktree, - directory: ctx.directory, - } - }) - - const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - return { branch, default_branch } - }) - - const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { - return yield* vcs.diff(ctx.query.mode) - }) - - const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { - return yield* command.list() - }) - - const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { - return yield* agent.list() - }) - - const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { - return yield* skill.all() - }) - - const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { - return yield* lsp.status() - }) - - const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { - return yield* format.status() - }) - - return handlers - .handle("dispose", dispose) - .handle("path", getPath) - .handle("vcs", getVcs) - .handle("vcsDiff", getVcsDiff) - .handle("command", getCommand) - .handle("agent", getAgent) - .handle("skill", getSkill) - .handle("lsp", getLsp) - .handle("formatter", getFormatter) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/mcp.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index f5552f6f2f08..149f8814a912 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -1,26 +1,27 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" -const AddPayload = Schema.Struct({ +export const AddPayload = Schema.Struct({ name: Schema.String, config: ConfigMCP.Info, -}).annotate({ identifier: "McpAddInput" }) +}) -const StatusMap = Schema.Record(Schema.String, MCP.Status) -const AuthStartResponse = Schema.Struct({ +export const StatusMap = Schema.Record(Schema.String, MCP.Status) +export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, - oauthState: Schema.String, -}).annotate({ identifier: "McpAuthStartResponse" }) -const AuthCallbackPayload = Schema.Struct({ +}) +export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, -}).annotate({ identifier: "McpAuthCallbackInput" }) -const AuthRemoveResponse = Schema.Struct({ +}) +export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), -}).annotate({ identifier: "McpAuthRemoveResponse" }) -class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( +}) +export class UnsupportedOAuthError extends Schema.ErrorClass("McpUnsupportedOAuthError")( { error: Schema.String }, { httpApiStatus: 400 }, ) {} @@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { - success: Schema.Record(Schema.String, MCP.Status), + success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.status", @@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("add", McpPaths.status, { payload: AddPayload, - success: StatusMap, + success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ @@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, - success: AuthStartResponse, - error: UnsupportedOAuthError, + success: described(AuthStartResponse, "OAuth flow started"), + error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.start", @@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, payload: AuthCallbackPayload, - success: MCP.Status, + success: described(MCP.Status, "OAuth authentication completed"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.callback", @@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, - success: MCP.Status, - error: UnsupportedOAuthError, + success: described(MCP.Status, "OAuth authentication completed"), + error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.authenticate", @@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, - success: AuthRemoveResponse, + success: described(AuthRemoveResponse, "OAuth credentials removed"), + error: HttpApiError.NotFound, }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.remove", @@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, - success: Schema.Boolean, + success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.connect", @@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, - success: Schema.Boolean, + success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ identifier: "mcp.disconnect", @@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi MCP routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) => - Effect.gen(function* () { - const mcp = yield* MCP.Service - - const status = Effect.fn("McpHttpApi.status")(function* () { - return yield* mcp.status() - }) - - const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { - const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status - return yield* Schema.decodeUnknownEffect(StatusMap)( - "status" in result ? { [ctx.payload.name]: result } : result, - ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - }) - - const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.startAuth(ctx.params.name) - }) - - const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { - params: { name: string } - payload: typeof AuthCallbackPayload.Type - }) { - return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) - }) - - const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.authenticate(ctx.params.name) - }) - - const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { - yield* mcp.removeAuth(ctx.params.name) - return { success: true as const } - }) - - const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { - yield* mcp.connect(ctx.params.name) - return true - }) - - const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { - yield* mcp.disconnect(ctx.params.name) - return true - }) - - return handlers - .handle("status", status) - .handle("add", add) - .handle("authStart", authStart) - .handle("authCallback", authCallback) - .handle("authAuthenticate", authAuthenticate) - .handle("authRemove", authRemove) - .handle("connect", connect) - .handle("disconnect", disconnect) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts new file mode 100644 index 000000000000..f4841c538dcc --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts @@ -0,0 +1,18 @@ +import { Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" + +export function described(schema: S, description: string): S { + return schema.annotate({ description }) as S +} + +export function responseDescription(description: string) { + return OpenApi.annotations({ + transform: (operation) => { + const response = operation.responses?.["200"] + if (response && typeof response === "object" && "description" in response) { + response.description = description + } + return operation + }, + }) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts similarity index 57% rename from packages/opencode/src/server/routes/instance/httpapi/permission.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 357c832990ae..e06c98d9eff2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -1,17 +1,23 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/permission" +const ReplyPayload = Schema.Struct({ + reply: Permission.Reply, + message: Schema.optional(Schema.String), +}) export const PermissionApi = HttpApi.make("permission") .add( HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Permission.Request), + success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ identifier: "permission.list", @@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, - payload: Permission.ReplyBody, - success: Schema.Boolean, + payload: ReplyPayload, + success: described(Schema.Boolean, "Permission processed successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "permission.reply", @@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi permission routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) => - Effect.gen(function* () { - const svc = yield* Permission.Service - - const list = Effect.fn("PermissionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - reply: ctx.payload.reply, - message: ctx.payload.message, - }) - return true - }) - - return handlers.handle("list", list).handle("reply", reply) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts similarity index 50% rename from packages/opencode/src/server/routes/instance/httpapi/project.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 276798b0b946..92019866e9e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -1,21 +1,24 @@ -import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" import { Project } from "@/project/project" -import { InstanceBootstrap } from "@/project/bootstrap" import { ProjectID } from "@/project/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" -import { markInstanceForReload } from "./lifecycle" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/project" +const UpdatePayload = Schema.Struct({ + name: Schema.optional(Schema.String), + icon: Schema.optional(Project.Info.fields.icon), + commands: Schema.optional(Project.Info.fields.commands), +}) export const ProjectApi = HttpApi.make("project") .add( HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Project.Info), + success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ identifier: "project.list", @@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { - success: Project.Info, + success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ identifier: "project.current", @@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { - success: Project.Info, + success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ identifier: "project.initGit", @@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, - payload: Project.UpdatePayload, - success: Project.Info, + payload: UpdatePayload, + success: described(Project.Info, "Updated project information"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "project.update", @@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi project routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project") description: "Experimental HttpApi surface for selected instance routes.", }), ) - -export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) => - Effect.gen(function* () { - const svc = yield* Project.Service - - const list = Effect.fn("ProjectHttpApi.list")(function* () { - return yield* svc.list() - }) - - const current = Effect.fn("ProjectHttpApi.current")(function* () { - return (yield* InstanceState.context).project - }) - - const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { - const ctx = yield* InstanceState.context - const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) - if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) - return next - yield* markInstanceForReload(ctx, { - directory: ctx.directory, - worktree: ctx.directory, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) - return next - }) - - const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } - payload: Project.UpdatePayload - }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) - }) - - return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts new file mode 100644 index 000000000000..56dace0e5ead --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -0,0 +1,74 @@ +import { ProviderAuth } from "@/provider/auth" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/provider" + +export const ProviderApi = HttpApi.make("provider") + .add( + HttpApiGroup.make("provider") + .add( + HttpApiEndpoint.get("list", root, { + success: described(Provider.ListResult, "List of providers"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.list", + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + }), + ), + HttpApiEndpoint.get("auth", `${root}/auth`, { + success: described(ProviderAuth.Methods, "Provider auth methods"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.auth", + summary: "Get provider auth methods", + description: "Retrieve available authentication methods for all AI providers.", + }), + ), + HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.AuthorizeInput, + success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.authorize", + summary: "Start OAuth authorization", + description: "Start the OAuth authorization flow for a provider.", + }), + ), + HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { + params: { providerID: ProviderID }, + payload: ProviderAuth.CallbackInput, + success: described(Schema.Boolean, "OAuth callback processed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "provider.oauth.callback", + summary: "Handle OAuth callback", + description: "Handle the OAuth callback from a provider after user authorization.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "provider", + description: "Experimental HttpApi provider routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .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/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts new file mode 100644 index 000000000000..e3914579c12f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -0,0 +1,121 @@ +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/pty" +export const Params = Schema.Struct({ ptyID: PtyID }) +export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const ShellItem = Schema.Struct({ + path: Schema.String, + name: Schema.String, + acceptable: Schema.Boolean, +}) + +export const PtyPaths = { + shells: `${root}/shells`, + list: root, + create: root, + get: `${root}/:ptyID`, + update: `${root}/:ptyID`, + remove: `${root}/:ptyID`, + connect: `${root}/:ptyID/connect`, +} as const + +export const PtyApi = HttpApi.make("pty") + .add( + HttpApiGroup.make("pty") + .add( + HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.shells", + summary: "List available shells", + description: "Get a list of available shells on the system.", + }), + ), + HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.list", + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + }), + ), + HttpApiEndpoint.post("create", PtyPaths.create, { + payload: Pty.CreateInput, + success: described(Pty.Info, "Created session"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.create", + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + }), + ), + HttpApiEndpoint.get("get", PtyPaths.get, { + params: { ptyID: PtyID }, + success: described(Pty.Info, "Session info"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.get", + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.put("update", PtyPaths.update, { + params: { ptyID: PtyID }, + payload: Pty.UpdateInput, + success: described(Pty.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.update", + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + }), + ), + HttpApiEndpoint.delete("remove", PtyPaths.remove, { + params: { ptyID: PtyID }, + success: described(Schema.Boolean, "Session removed"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.remove", + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const PtyConnectApi = HttpApi.make("pty-connect").add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { + params: Params, + success: described(Schema.Boolean, "Connected session"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: + "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts similarity index 58% rename from packages/opencode/src/server/routes/instance/httpapi/question.ts rename to packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index 2169e17c5cc5..de249823b7ef 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -1,17 +1,24 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" const root = "/question" +const ReplyPayload = Schema.Struct({ + answers: Schema.Array(Question.Answer).annotate({ + description: "User answers in order of questions (each answer is an array of selected labels)", + }), +}) export const QuestionApi = HttpApi.make("question") .add( HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { - success: Schema.Array(Question.Request), + success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ identifier: "question.list", @@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, - payload: Question.Reply, - success: Schema.Boolean, + payload: ReplyPayload, + success: described(Schema.Boolean, "Question answered successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "question.reply", @@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, - success: Schema.Boolean, + success: described(Schema.Boolean, "Question rejected successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "question.reject", @@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question") description: "Question routes.", }), ) + .middleware(InstanceContextMiddleware) .middleware(Authorization), ) .annotateMerge( @@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question") description: "Effect HttpApi surface for instance routes.", }), ) - -export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) => - Effect.gen(function* () { - const svc = yield* Question.Service - - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() - }) - - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Question.Reply - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { - yield* svc.reject(ctx.params.requestID) - return true - }) - - return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts new file mode 100644 index 000000000000..5a388f187682 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -0,0 +1,428 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { Session } from "@/session/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { Snapshot } from "@/snapshot" +import { NonNegativeInt } from "@/util/schema" +import { Schema, SchemaGetter, Struct } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/session" +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) +export const ListQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["project"])), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + limit: Schema.optional(Schema.NumberFromString), +}) +export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const MessagesQuery = Schema.Struct({ + limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), + before: Schema.optional(Schema.String), +}) +export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) +export const UpdatePayload = Schema.Struct({ + title: Schema.optional(Schema.String), + permission: Schema.optional(Permission.Ruleset), + time: Schema.optional( + Schema.Struct({ + archived: Schema.optional(NonNegativeInt), + }), + ), +}) +export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])) +export const InitPayload = Schema.Struct({ + modelID: ModelID, + providerID: ProviderID, + messageID: MessageID, +}) +export const SummarizePayload = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + auto: Schema.optional(Schema.Boolean), +}) +export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) +export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) +export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])) +export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])) +export const PermissionResponsePayload = Schema.Struct({ + response: Permission.Reply, +}) + +export const SessionPaths = { + list: root, + status: `${root}/status`, + get: `${root}/:sessionID`, + children: `${root}/:sessionID/children`, + todo: `${root}/:sessionID/todo`, + diff: `${root}/:sessionID/diff`, + messages: `${root}/:sessionID/message`, + message: `${root}/:sessionID/message/:messageID`, + create: root, + remove: `${root}/:sessionID`, + update: `${root}/:sessionID`, + fork: `${root}/:sessionID/fork`, + abort: `${root}/:sessionID/abort`, + share: `${root}/:sessionID/share`, + init: `${root}/:sessionID/init`, + summarize: `${root}/:sessionID/summarize`, + prompt: `${root}/:sessionID/message`, + promptAsync: `${root}/:sessionID/prompt_async`, + command: `${root}/:sessionID/command`, + shell: `${root}/:sessionID/shell`, + revert: `${root}/:sessionID/revert`, + unrevert: `${root}/:sessionID/unrevert`, + permissions: `${root}/:sessionID/permissions/:permissionID`, + deleteMessage: `${root}/:sessionID/message/:messageID`, + deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, + updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, +} as const + +export const SessionApi = HttpApi.make("session") + .add( + HttpApiGroup.make("session") + .add( + HttpApiEndpoint.get("list", SessionPaths.list, { + query: ListQuery, + success: described(Schema.Array(Session.Info), "List of sessions"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.list", + summary: "List sessions", + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", + }), + ), + HttpApiEndpoint.get("status", SessionPaths.status, { + success: described(StatusMap, "Get session status"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.status", + summary: "Get session status", + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", + }), + ), + HttpApiEndpoint.get("get", SessionPaths.get, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Get session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.get", + summary: "Get session", + description: "Retrieve detailed information about a specific OpenCode session.", + }), + ), + HttpApiEndpoint.get("children", SessionPaths.children, { + params: { sessionID: SessionID }, + success: described(Schema.Array(Session.Info), "List of children"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.children", + summary: "Get session children", + description: "Retrieve all child sessions that were forked from the specified parent session.", + }), + ), + HttpApiEndpoint.get("todo", SessionPaths.todo, { + params: { sessionID: SessionID }, + success: described(Schema.Array(Todo.Info), "Todo list"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.todo", + summary: "Get session todos", + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", + }), + ), + HttpApiEndpoint.get("diff", SessionPaths.diff, { + params: { sessionID: SessionID }, + query: DiffQuery, + success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.diff", + summary: "Get message diff", + description: "Get the file changes (diff) that resulted from a specific user message in the session.", + }), + ), + HttpApiEndpoint.get("messages", SessionPaths.messages, { + params: { sessionID: SessionID }, + query: MessagesQuery, + success: described(Schema.Array(MessageV2.WithParts), "List of messages"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.messages", + summary: "Get session messages", + description: "Retrieve all messages in a session, including user prompts and AI responses.", + }), + ), + HttpApiEndpoint.get("message", SessionPaths.message, { + params: { sessionID: SessionID, messageID: MessageID }, + success: described(MessageV2.WithParts, "Message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.message", + summary: "Get message", + description: "Retrieve a specific message from a session by its message ID.", + }), + ), + HttpApiEndpoint.post("create", SessionPaths.create, { + payload: [HttpApiSchema.NoContent, Session.CreateInput], + success: described(Session.Info, "Successfully created session"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.create", + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + }), + ), + HttpApiEndpoint.delete("remove", SessionPaths.remove, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Successfully deleted session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.delete", + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + }), + ), + HttpApiEndpoint.patch("update", SessionPaths.update, { + params: { sessionID: SessionID }, + payload: UpdatePayload, + success: described(Session.Info, "Successfully updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.update", + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + }), + ), + HttpApiEndpoint.post("fork", SessionPaths.fork, { + params: { sessionID: SessionID }, + payload: ForkPayload, + success: described(Session.Info, "200"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.fork", + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + }), + ), + HttpApiEndpoint.post("abort", SessionPaths.abort, { + params: { sessionID: SessionID }, + success: described(Schema.Boolean, "Aborted session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.abort", + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + }), + ), + HttpApiEndpoint.post("init", SessionPaths.init, { + params: { sessionID: SessionID }, + payload: InitPayload, + success: described(Schema.Boolean, "200"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.init", + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + }), + ), + HttpApiEndpoint.post("share", SessionPaths.share, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Successfully shared session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.share", + summary: "Share session", + description: "Create a shareable link for a session, allowing others to view the conversation.", + }), + ), + HttpApiEndpoint.delete("unshare", SessionPaths.share, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Successfully unshared session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unshare", + summary: "Unshare session", + description: "Remove the shareable link for a session, making it private again.", + }), + ), + HttpApiEndpoint.post("summarize", SessionPaths.summarize, { + params: { sessionID: SessionID }, + payload: SummarizePayload, + success: described(Schema.Boolean, "Summarized session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.summarize", + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + }), + ), + HttpApiEndpoint.post("prompt", SessionPaths.prompt, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt", + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + }), + ), + HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: described(HttpApiSchema.NoContent, "Prompt accepted"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt_async", + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + }), + ), + HttpApiEndpoint.post("command", SessionPaths.command, { + params: { sessionID: SessionID }, + payload: CommandPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.command", + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + }), + ), + HttpApiEndpoint.post("shell", SessionPaths.shell, { + params: { sessionID: SessionID }, + payload: ShellPayload, + success: described(MessageV2.WithParts, "Created message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.shell", + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + }), + ), + HttpApiEndpoint.post("revert", SessionPaths.revert, { + params: { sessionID: SessionID }, + payload: RevertPayload, + success: described(Session.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.revert", + summary: "Revert message", + description: + "Revert a specific message in a session, undoing its effects and restoring the previous state.", + }), + ), + HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { + params: { sessionID: SessionID }, + success: described(Session.Info, "Updated session"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unrevert", + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + }), + ), + HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { + params: { sessionID: SessionID, permissionID: PermissionID }, + payload: PermissionResponsePayload, + success: described(Schema.Boolean, "Permission processed successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.respond", + summary: "Respond to permission", + description: "Approve or deny a permission request from the AI assistant.", + deprecated: true, + }), + ), + HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { + params: { sessionID: SessionID, messageID: MessageID }, + success: described(Schema.Boolean, "Successfully deleted message"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.deleteMessage", + summary: "Delete message", + description: + "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + }), + ), + HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + success: described(Schema.Boolean, "Successfully deleted part"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.delete", + description: "Delete a part from a message.", + }), + ), + HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { + params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + payload: MessageV2.Part, + success: described(MessageV2.Part, "Successfully updated part"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "part.update", + description: "Update a part in a message.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "session", + description: "Experimental HttpApi session routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .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/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts new file mode 100644 index 000000000000..1d9b08d9cb83 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -0,0 +1,90 @@ +import { NonNegativeInt } from "@/util/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/sync" +export const ReplayEvent = Schema.Struct({ + id: Schema.String, + aggregateID: Schema.String, + seq: NonNegativeInt, + type: Schema.String, + data: Schema.Record(Schema.String, Schema.Unknown), +}) +export const ReplayPayload = Schema.Struct({ + directory: Schema.String, + events: Schema.NonEmptyArray(ReplayEvent), +}) +export const ReplayResponse = Schema.Struct({ + sessionID: Schema.String, +}) +export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) +export const HistoryEvent = Schema.Struct({ + id: Schema.String, + aggregate_id: Schema.String, + seq: NonNegativeInt, + type: Schema.String, + data: Schema.Record(Schema.String, Schema.Unknown), +}) + +export const SyncPaths = { + start: `${root}/start`, + replay: `${root}/replay`, + history: `${root}/history`, +} as const + +export const SyncApi = HttpApi.make("sync") + .add( + HttpApiGroup.make("sync") + .add( + HttpApiEndpoint.post("start", SyncPaths.start, { + success: described(Schema.Boolean, "Workspace sync started"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.start", + summary: "Start workspace sync", + description: "Start sync loops for workspaces in the current project that have active sessions.", + }), + ), + HttpApiEndpoint.post("replay", SyncPaths.replay, { + payload: ReplayPayload, + success: described(ReplayResponse, "Replayed sync events"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.replay", + summary: "Replay sync events", + description: "Validate and replay a complete sync event history.", + }), + ), + HttpApiEndpoint.post("history", SyncPaths.history, { + payload: HistoryPayload, + success: described(Schema.Array(HistoryEvent), "Sync events"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.history.list", + 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.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "sync", + description: "Experimental HttpApi sync routes.", + }), + ) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .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/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts new file mode 100644 index 000000000000..a5d31bfa6212 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -0,0 +1,164 @@ +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/tui" +export const CommandPayload = Schema.Struct({ command: Schema.String }) +export const TuiRequestPayload = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, +}) +const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }) +const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }) +const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }) +const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }) +export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect]) + +export const TuiPaths = { + appendPrompt: `${root}/append-prompt`, + openHelp: `${root}/open-help`, + openSessions: `${root}/open-sessions`, + openThemes: `${root}/open-themes`, + openModels: `${root}/open-models`, + submitPrompt: `${root}/submit-prompt`, + clearPrompt: `${root}/clear-prompt`, + executeCommand: `${root}/execute-command`, + showToast: `${root}/show-toast`, + publish: `${root}/publish`, + selectSession: `${root}/select-session`, + controlNext: `${root}/control/next`, + controlResponse: `${root}/control/response`, +} as const + +export const TuiApi = HttpApi.make("tui") + .add( + HttpApiGroup.make("tui") + .add( + HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + payload: TuiEvent.PromptAppend.properties, + success: described(Schema.Boolean, "Prompt processed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.appendPrompt", + summary: "Append TUI prompt", + description: "Append prompt to the TUI.", + }), + ), + HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openHelp", + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + }), + ), + HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openSessions", + summary: "Open sessions dialog", + description: "Open the session dialog.", + }), + ), + HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openThemes", + summary: "Open themes dialog", + description: "Open the theme dialog.", + }), + ), + HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.openModels", + summary: "Open models dialog", + description: "Open the model dialog.", + }), + ), + HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.submitPrompt", + summary: "Submit TUI prompt", + description: "Submit the prompt.", + }), + ), + HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.clearPrompt", + summary: "Clear TUI prompt", + description: "Clear the prompt.", + }), + ), + HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + payload: CommandPayload, + success: described(Schema.Boolean, "Command executed successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.executeCommand", + summary: "Execute TUI command", + description: "Execute a TUI command.", + }), + ), + HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + payload: TuiEvent.ToastShow.properties, + success: described(Schema.Boolean, "Toast notification shown successfully"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.showToast", + summary: "Show TUI toast", + description: "Show a toast notification in the TUI.", + }), + ), + HttpApiEndpoint.post("publish", TuiPaths.publish, { + payload: TuiPublishPayload, + success: described(Schema.Boolean, "Event published successfully"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.publish", + summary: "Publish TUI event", + description: "Publish a TUI event.", + }), + ), + HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + payload: TuiEvent.SessionSelect.properties, + success: described(Schema.Boolean, "Session selected successfully"), + error: [HttpApiError.BadRequest, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.selectSession", + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + }), + ), + HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.next", + summary: "Get next TUI request", + description: "Retrieve the next TUI request from the queue for processing.", + }), + ), + HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + payload: Schema.Unknown, + success: described(Schema.Boolean, "Response submitted successfully"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.control.response", + summary: "Submit TUI response", + description: "Submit a response to the TUI request queue to complete a pending request.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .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/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts new file mode 100644 index 000000000000..0305c65365d7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -0,0 +1,103 @@ +import { Workspace } from "@/control-plane/workspace" +import { WorkspaceAdaptorEntry } 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 { Authorization } from "../auth" +import { InstanceContextMiddleware } from "../instance-context" +import { described } from "./metadata" + +const root = "/experimental/workspace" +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const SessionRestorePayload = Schema.Struct( + Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), +) +export const SessionRestoreResponse = Schema.Struct({ + total: NonNegativeInt, +}) + +export const WorkspacePaths = { + adaptors: `${root}/adaptor`, + list: root, + status: `${root}/status`, + remove: `${root}/:id`, + sessionRestore: `${root}/:id/session-restore`, +} as const + +export const WorkspaceApi = HttpApi.make("workspace") + .add( + HttpApiGroup.make("workspace") + .add( + HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { + success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.adaptor.list", + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + }), + ), + HttpApiEndpoint.get("list", WorkspacePaths.list, { + success: described(Schema.Array(Workspace.Info), "Workspaces"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.list", + summary: "List workspaces", + description: "List all workspaces.", + }), + ), + HttpApiEndpoint.post("create", WorkspacePaths.list, { + payload: CreatePayload, + success: described(Workspace.Info, "Workspace created"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.create", + summary: "Create workspace", + description: "Create a workspace for the current project.", + }), + ), + HttpApiEndpoint.get("status", WorkspacePaths.status, { + success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.status", + summary: "Workspace status", + description: "Get connection status for workspaces in the current project.", + }), + ), + HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { + params: { id: Workspace.Info.fields.id }, + success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.workspace.remove", + summary: "Remove 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"), + 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.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) + .middleware(InstanceContextMiddleware) + .middleware(Authorization), + ) + .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/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts new file mode 100644 index 000000000000..2fc225d1714c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -0,0 +1,34 @@ +import { Config } from "@/config/config" +import { Provider } from "@/provider/provider" +import * as InstanceState from "@/effect/instance-state" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) => + Effect.gen(function* () { + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) + + const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { + yield* configSvc.update(ctx.payload, { dispose: false }) + yield* markInstanceForDisposal(yield* InstanceState.context) + return ctx.payload + }) + + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const providers = yield* providerSvc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }) + + return handlers.handle("get", get).handle("update", update).handle("providers", providers) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts new file mode 100644 index 000000000000..abddd8c40235 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -0,0 +1,34 @@ +import { Auth } from "@/auth" +import { ProviderID } from "@/provider/schema" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { RootHttpApi } from "../api" +import { LogInput } from "../groups/control" + +export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => + Effect.gen(function* () { + const auth = yield* Auth.Service + + const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { + params: { providerID: ProviderID } + payload: Auth.Info + }) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + }) + + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + }) + + const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { + const logger = Log.create({ service: ctx.payload.service }) + logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) + return true + }) + + return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts new file mode 100644 index 000000000000..42eab762e8da --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -0,0 +1,155 @@ +import { Account } from "@/account/account" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { MCP } from "@/mcp" +import { Project } from "@/project/project" +import { Session } from "@/session/session" +import { ToolRegistry } from "@/tool/registry" +import * as EffectZod from "@/util/effect-zod" +import { Worktree } from "@/worktree" +import { Effect, Option } from "effect" +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" + +export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => + Effect.gen(function* () { + const account = yield* Account.Service + const agents = yield* Agent.Service + const config = yield* Config.Service + const mcp = yield* MCP.Service + const project = yield* Project.Service + const registry = yield* ToolRegistry.Service + const worktreeSvc = yield* Worktree.Service + + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { + const [state, groups] = yield* Effect.all( + [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + return { + consoleManagedProviders: state.consoleManagedProviders, + ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }) + + const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { + const [groups, active] = yield* Effect.all( + [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + const info = Option.getOrUndefined(active) + return { + orgs: groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ), + } + }) + + const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { + payload: typeof ConsoleSwitchPayload.Type + }) { + yield* account + .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { + const list = yield* registry.tools({ + providerID: ctx.query.provider, + modelID: ctx.query.model, + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + return list.map((item) => ({ + id: item.id, + description: item.description, + parameters: EffectZod.toJsonSchema(item.parameters), + })) + }) + + const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { + return yield* registry.ids() + }) + + const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { + const ctx = yield* InstanceState.context + return yield* project.sandboxes(ctx.project.id) + }) + + const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { + payload: Worktree.CreateInput | undefined + }) { + return yield* worktreeSvc.create(ctx.payload) + }) + + const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { + payload: Worktree.RemoveInput + }) { + const ctx = yield* InstanceState.context + yield* worktreeSvc.remove(input.payload) + yield* project.removeSandbox(ctx.project.id, input.payload.directory) + return true + }) + + const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { + payload: Worktree.ResetInput + }) { + yield* worktreeSvc.reset(ctx.payload) + return true + }) + + const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { + const limit = ctx.query.limit ?? 100 + const sessions = Array.from( + Session.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, + }) + }) + + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { + return yield* mcp.resources() + }) + + return handlers + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + .handle("session", session) + .handle("resource", resource) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts new file mode 100644 index 000000000000..72133e8dea30 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -0,0 +1,54 @@ +import * as InstanceState from "@/effect/instance-state" +import { File } from "@/file" +import { Ripgrep } from "@/file/ripgrep" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => + Effect.gen(function* () { + const svc = yield* File.Service + const ripgrep = yield* Ripgrep.Service + + const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + return (yield* ripgrep + .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) + .pipe(Effect.orDie)).items + }) + + const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } + }) { + return yield* svc.search({ + query: ctx.query.query, + limit: ctx.query.limit ?? 10, + dirs: ctx.query.dirs !== "false", + type: ctx.query.type, + }) + }) + + const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { + return [] + }) + + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { + return yield* svc.list(ctx.query.path) + }) + + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return yield* svc.read(ctx.query.path) + }) + + const status = Effect.fn("FileHttpApi.status")(function* () { + return yield* svc.status() + }) + + return handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts new file mode 100644 index 000000000000..5972395512af --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -0,0 +1,156 @@ +import { Config } from "@/config/config" +import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { Installation } from "@/installation" +import { Instance } from "@/project/instance" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import * as Log from "@opencode-ai/core/util/log" +import { Effect, Queue, Schema } from "effect" +import * as Stream from "effect/Stream" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { RootHttpApi } from "../api" +import { GlobalUpgradeInput } from "../groups/global" + +const log = Log.create({ service: "server" }) + +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } +} + +function parseBody(body: string) { + try { + return JSON.parse(body || "{}") as unknown + } catch { + return undefined + } +} + +function eventResponse() { + log.info("global event connected") + const events = Stream.callback((queue) => { + const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event) + return Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", handler)), + () => Effect.sync(() => GlobalBus.off("event", handler)), + ) + }) + const heartbeat = Stream.tick("10 seconds").pipe( + Stream.drop(1), + Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + ) + + return HttpServerResponse.stream( + Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) +} + +export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) => + Effect.gen(function* () { + const config = yield* Config.Service + const installation = yield* Installation.Service + + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } + }) + + const event = Effect.fn("GlobalHttpApi.event")(function* () { + return eventResponse() + }) + + const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { + return yield* config.getGlobal() + }) + + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { + return yield* config.updateGlobal(ctx.payload) + }) + + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { + yield* Effect.promise(() => Instance.disposeAll()) + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.disposed", properties: {} }, + }) + return true + }) + + const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { + const method = yield* installation.method() + if (method === "unknown") { + return { + status: 400, + body: { success: false as const, error: "Unknown installation method" }, + } + } + const target = ctx.payload.target || (yield* installation.latest(method)) + const result = yield* installation.upgrade(method, target).pipe( + Effect.as({ status: 200, body: { success: true as const, version: target } }), + Effect.catch((err) => + Effect.succeed({ + status: 500, + body: { + success: false as const, + error: err instanceof Error ? err.message : String(err), + }, + }), + ), + ) + if (!result.body.success) return result + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, + }) + return result + }) + + const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const json = parseBody(body) + if (json === undefined) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( + Effect.map((payload) => ({ valid: true as const, payload })), + Effect.catch(() => Effect.succeed({ valid: false as const })), + ) + if (!payload.valid) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const result = yield* upgrade({ payload: payload.payload }) + return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) + }) + + return handlers + .handle("health", health) + .handleRaw("event", event) + .handle("configGet", configGet) + .handle("configUpdate", configUpdate) + .handle("dispose", dispose) + .handleRaw("upgrade", upgradeRaw) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts new file mode 100644 index 000000000000..b6f38606520a --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -0,0 +1,79 @@ +import { Agent } from "@/agent/agent" +import { Command } from "@/command" +import * as InstanceState from "@/effect/instance-state" +import { Format } from "@/format" +import { Global } from "@opencode-ai/core/global" +import { LSP } from "@/lsp/lsp" +import { Vcs } from "@/project/vcs" +import { Skill } from "@/skill" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForDisposal } from "../lifecycle" + +export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => + Effect.gen(function* () { + const agent = yield* Agent.Service + const command = yield* Command.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const skill = yield* Skill.Service + const vcs = yield* Vcs.Service + + const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { + yield* markInstanceForDisposal(yield* InstanceState.context) + return true + }) + + const getPath = Effect.fn("InstanceHttpApi.path")(function* () { + const ctx = yield* InstanceState.context + return { + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: ctx.worktree, + directory: ctx.directory, + } + }) + + const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + return { branch, default_branch } + }) + + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { + return yield* vcs.diff(ctx.query.mode) + }) + + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { + return yield* command.list() + }) + + const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { + return yield* agent.list() + }) + + const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { + return yield* skill.all() + }) + + const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { + return yield* lsp.status() + }) + + const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { + return yield* format.status() + }) + + return handlers + .handle("dispose", dispose) + .handle("path", getPath) + .handle("vcs", getVcs) + .handle("vcsDiff", getVcsDiff) + .handle("command", getCommand) + .handle("agent", getAgent) + .handle("skill", getSkill) + .handle("lsp", getLsp) + .handle("formatter", getFormatter) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts new file mode 100644 index 000000000000..b4d27d91de6c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -0,0 +1,68 @@ +import { MCP } from "@/mcp" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" + +export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => + Effect.gen(function* () { + const mcp = yield* MCP.Service + + const status = Effect.fn("McpHttpApi.status")(function* () { + return yield* mcp.status() + }) + + const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status + return yield* Schema.decodeUnknownEffect(StatusMap)( + "status" in result ? { [ctx.payload.name]: result } : result, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + }) + + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.startAuth(ctx.params.name) + }) + + const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { + params: { name: string } + payload: typeof AuthCallbackPayload.Type + }) { + return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) + }) + + const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.authenticate(ctx.params.name) + }) + + const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { + yield* mcp.removeAuth(ctx.params.name) + return { success: true as const } + }) + + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + yield* mcp.connect(ctx.params.name) + return true + }) + + const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + yield* mcp.disconnect(ctx.params.name) + return true + }) + + return handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .handle("connect", connect) + .handle("disconnect", disconnect) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts new file mode 100644 index 000000000000..a5d6dab89514 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -0,0 +1,29 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return handlers.handle("list", list).handle("reply", reply) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts new file mode 100644 index 000000000000..20a5ddfb09c3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -0,0 +1,46 @@ +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" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { markInstanceForReload } from "../lifecycle" + +export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => + Effect.gen(function* () { + const svc = yield* Project.Service + + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) + + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return (yield* InstanceState.context).project + }) + + const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { + const ctx = yield* InstanceState.context + const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) + if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) + return next + yield* markInstanceForReload(ctx, { + directory: ctx.directory, + worktree: ctx.directory, + project: next, + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) + return next + }) + + const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { + params: { projectID: ProjectID } + payload: Project.UpdatePayload + }) { + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + }) + + return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts new file mode 100644 index 000000000000..f343829d6aa1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -0,0 +1,89 @@ +import { ProviderAuth } from "@/provider/auth" +import { Config } from "@/config/config" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { mapValues } from "remeda" +import { Effect, Schema } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) => + Effect.gen(function* () { + const cfg = yield* Config.Service + const provider = yield* Provider.Service + const svc = yield* ProviderAuth.Service + + const list = Effect.fn("ProviderHttpApi.list")(function* () { + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value + } + const connected = yield* provider.list() + const providers = Object.assign( + mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }) + + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) + + const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.AuthorizeInput + }) { + return yield* svc + .authorize({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + inputs: ctx.payload.inputs, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }) + + const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { + params: { providerID: ProviderID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + const result = yield* authorize({ params: ctx.params, payload }) + if (result === undefined) return HttpServerResponse.empty({ status: 200 }) + return HttpServerResponse.jsonUnsafe(result) + }) + + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.CallbackInput + }) { + yield* svc + .callback({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + code: ctx.payload.code, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) + + return handlers + .handle("list", list) + .handle("auth", auth) + .handleRaw("authorize", authorizeRaw) + .handle("callback", callback) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts new file mode 100644 index 000000000000..f2f17d4714c8 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -0,0 +1,118 @@ +import { EffectBridge } from "@/effect/bridge" +import { Pty } from "@/pty" +import { PtyID } from "@/pty/schema" +import { Shell } from "@/shell/shell" +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" + +export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => + Effect.gen(function* () { + const pty = yield* Pty.Service + + const shells = Effect.fn("PtyHttpApi.shells")(function* () { + return yield* Effect.promise(() => Shell.list()) + }) + + const list = Effect.fn("PtyHttpApi.list")(function* () { + return yield* pty.list() + }) + + const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { + const bridge = yield* EffectBridge.make() + return yield* Effect.promise(() => + bridge.promise( + pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + env: ctx.payload.env ? { ...ctx.payload.env } : undefined, + }), + ), + ) + }) + + const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { + const info = yield* pty.get(ctx.params.ptyID) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { + params: { ptyID: PtyID } + payload: typeof Pty.UpdateInput.Type + }) { + const info = yield* pty.update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, + }) + if (!info) return yield* new HttpApiError.NotFound({}) + return info + }) + + const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { + yield* pty.remove(ctx.params.ptyID) + return true + }) + + return handlers + .handle("shells", shells) + .handle("list", list) + .handle("create", create) + .handle("get", get) + .handle("update", update) + .handle("remove", remove) + }), +) + +export const ptyConnectRoute = HttpRouter.add( + "GET", + PtyPaths.connect, + Effect.gen(function* () { + const pty = yield* Pty.Service + const params = yield* HttpRouter.schemaPathParams(Params) + if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) + + const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + 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 write = yield* socket.writer + let closed = false + const adapter = { + get readyState() { + return closed ? 3 : 1 + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + if (closed) return + Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void))) + }, + close: (code?: number, reason?: string) => { + if (closed) return + closed = true + Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) + }, + } + const handler = yield* pty.connect(params.ptyID, adapter, cursor) + if (!handler) return HttpServerResponse.empty() + + yield* socket + .runRaw((message) => { + handler.onMessage(typeof message === "string" ? message : message.slice().buffer) + }) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) + return HttpServerResponse.empty() + }).pipe(Effect.provide(Pty.defaultLayer)), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts new file mode 100644 index 000000000000..53ca568cf5bd --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts @@ -0,0 +1,33 @@ +import { Question } from "@/question" +import { QuestionID } from "@/question/schema" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" + +export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) => + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) + + return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/session.ts rename to packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 6ea19f19e473..d6264b605018 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -6,7 +6,6 @@ import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" -import { Snapshot } from "@/snapshot" +import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Schema, SchemaGetter, Struct } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { - HttpApi, - HttpApiBuilder, - HttpApiEndpoint, - HttpApiError, - HttpApiGroup, - HttpApiSchema, - OpenApi, -} from "effect/unstable/httpapi" -import { Authorization } from "./auth" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session" const log = Log.create({ service: "server" }) -const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) -const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), - scope: Schema.optional(Schema.Literals(["project"])), - path: Schema.optional(Schema.String), - roots: Schema.optional(QueryBoolean), - start: Schema.optional(Schema.NumberFromString), - search: Schema.optional(Schema.String), - limit: Schema.optional(Schema.NumberFromString), -}) -const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) -const MessagesQuery = Schema.Struct({ - limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), - before: Schema.optional(Schema.String), -}) -const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) -const UpdatePayload = Schema.Struct({ - title: Schema.optional(Schema.String), - permission: Schema.optional(Permission.Ruleset), - time: Schema.optional( - Schema.Struct({ - archived: Schema.optional(Schema.Number), - }), - ), -}).annotate({ identifier: "SessionUpdateInput" }) -const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ - identifier: "SessionForkInput", -}) -const InitPayload = Schema.Struct({ - modelID: ModelID, - providerID: ProviderID, - messageID: MessageID, -}).annotate({ identifier: "SessionInitInput" }) -const SummarizePayload = Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - auto: Schema.optional(Schema.Boolean), -}).annotate({ identifier: "SessionSummarizeInput" }) -const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({ - identifier: "SessionPromptInput", -}) -const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({ - identifier: "SessionCommandInput", -}) -const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({ - identifier: "SessionShellInput", -}) -const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({ - identifier: "SessionRevertInput", -}) -const PermissionResponsePayload = Schema.Struct({ - response: Permission.Reply, -}).annotate({ identifier: "SessionPermissionResponseInput" }) - -export const SessionPaths = { - list: root, - status: `${root}/status`, - get: `${root}/:sessionID`, - children: `${root}/:sessionID/children`, - todo: `${root}/:sessionID/todo`, - diff: `${root}/:sessionID/diff`, - messages: `${root}/:sessionID/message`, - message: `${root}/:sessionID/message/:messageID`, - create: root, - remove: `${root}/:sessionID`, - update: `${root}/:sessionID`, - fork: `${root}/:sessionID/fork`, - abort: `${root}/:sessionID/abort`, - share: `${root}/:sessionID/share`, - init: `${root}/:sessionID/init`, - summarize: `${root}/:sessionID/summarize`, - prompt: `${root}/:sessionID/message`, - promptAsync: `${root}/:sessionID/prompt_async`, - command: `${root}/:sessionID/command`, - shell: `${root}/:sessionID/shell`, - revert: `${root}/:sessionID/revert`, - unrevert: `${root}/:sessionID/unrevert`, - permissions: `${root}/:sessionID/permissions/:permissionID`, - deleteMessage: `${root}/:sessionID/message/:messageID`, - deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, - updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, -} as const - -export const SessionApi = HttpApi.make("session") - .add( - HttpApiGroup.make("session") - .add( - HttpApiEndpoint.get("list", SessionPaths.list, { - query: ListQuery, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.list", - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - }), - ), - HttpApiEndpoint.get("status", SessionPaths.status, { - success: StatusMap, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.status", - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - }), - ), - HttpApiEndpoint.get("get", SessionPaths.get, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.get", - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - }), - ), - HttpApiEndpoint.get("children", SessionPaths.children, { - params: { sessionID: SessionID }, - success: Schema.Array(Session.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.children", - summary: "Get session children", - description: "Retrieve all child sessions that were forked from the specified parent session.", - }), - ), - HttpApiEndpoint.get("todo", SessionPaths.todo, { - params: { sessionID: SessionID }, - success: Schema.Array(Todo.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.todo", - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - }), - ), - HttpApiEndpoint.get("diff", SessionPaths.diff, { - params: { sessionID: SessionID }, - query: DiffQuery, - success: Schema.Array(Snapshot.FileDiff), - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.diff", - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - }), - ), - HttpApiEndpoint.get("messages", SessionPaths.messages, { - params: { sessionID: SessionID }, - query: MessagesQuery, - success: Schema.Array(MessageV2.WithParts), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.messages", - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - }), - ), - HttpApiEndpoint.get("message", SessionPaths.message, { - params: { sessionID: SessionID, messageID: MessageID }, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.message", - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - }), - ), - HttpApiEndpoint.post("create", SessionPaths.create, { - payload: [HttpApiSchema.NoContent, Session.CreateInput], - success: Session.Info, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.create", - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - }), - ), - HttpApiEndpoint.delete("remove", SessionPaths.remove, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.delete", - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - }), - ), - HttpApiEndpoint.patch("update", SessionPaths.update, { - params: { sessionID: SessionID }, - payload: UpdatePayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.update", - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - }), - ), - HttpApiEndpoint.post("fork", SessionPaths.fork, { - params: { sessionID: SessionID }, - payload: ForkPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.fork", - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - }), - ), - HttpApiEndpoint.post("abort", SessionPaths.abort, { - params: { sessionID: SessionID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.abort", - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - }), - ), - HttpApiEndpoint.post("init", SessionPaths.init, { - params: { sessionID: SessionID }, - payload: InitPayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.init", - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - }), - ), - HttpApiEndpoint.post("share", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.share", - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - }), - ), - HttpApiEndpoint.delete("unshare", SessionPaths.share, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unshare", - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - }), - ), - HttpApiEndpoint.post("summarize", SessionPaths.summarize, { - params: { sessionID: SessionID }, - payload: SummarizePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.summarize", - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - }), - ), - HttpApiEndpoint.post("prompt", SessionPaths.prompt, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt", - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - }), - ), - HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { - params: { sessionID: SessionID }, - payload: PromptPayload, - success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.prompt_async", - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - }), - ), - HttpApiEndpoint.post("command", SessionPaths.command, { - params: { sessionID: SessionID }, - payload: CommandPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.command", - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - }), - ), - HttpApiEndpoint.post("shell", SessionPaths.shell, { - params: { sessionID: SessionID }, - payload: ShellPayload, - success: MessageV2.WithParts, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.shell", - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - }), - ), - HttpApiEndpoint.post("revert", SessionPaths.revert, { - params: { sessionID: SessionID }, - payload: RevertPayload, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.revert", - summary: "Revert message", - description: - "Revert a specific message in a session, undoing its effects and restoring the previous state.", - }), - ), - HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { - params: { sessionID: SessionID }, - success: Session.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.unrevert", - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - }), - ), - HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { - params: { sessionID: SessionID, permissionID: PermissionID }, - payload: PermissionResponsePayload, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "permission.respond", - summary: "Respond to permission", - description: "Approve or deny a permission request from the AI assistant.", - deprecated: true, - }), - ), - HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { - params: { sessionID: SessionID, messageID: MessageID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.deleteMessage", - summary: "Delete message", - description: - "Permanently delete a specific message and all of its parts from a session without reverting file changes.", - }), - ), - HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.delete", - description: "Delete a part from a message.", - }), - ), - HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { - params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, - payload: MessageV2.Part, - success: MessageV2.Part, - }).annotateMerge( - OpenApi.annotations({ - identifier: "part.update", - description: "Update a part in a message.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "session", - description: "Experimental HttpApi session routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), + +const mapNotFound = (self: Effect.Effect) => + self.pipe( + Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), + Effect.catchDefect((error) => + NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error), + ), ) -export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) => +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service const statusSvc = yield* SessionStatus.Service @@ -462,7 +66,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand }) const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { - return yield* session.get(ctx.params.sessionID) + return yield* mapNotFound(session.get(ctx.params.sessionID)) }) const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { @@ -484,44 +88,47 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before - yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.limit === undefined || ctx.query.limit === 0) { - yield* session.get(ctx.params.sessionID) - return yield* session.messages({ sessionID: ctx.params.sessionID }) - } + return yield* mapNotFound(Effect.gen(function* () { + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } + if (ctx.query.limit === undefined || ctx.query.limit === 0) { + yield* session.get(ctx.params.sessionID) + return yield* session.messages({ sessionID: ctx.params.sessionID }) + } - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) - if (!page.cursor) return page.items - - const request = yield* HttpServerRequest.HttpServerRequest - const url = 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, { - headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", - Link: `<${url.toString()}>; rel="next"`, - "X-Next-Cursor": page.cursor, - }, - }) + yield* session.get(ctx.params.sessionID) + const page = MessageV2.page({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }) + if (!page.cursor) return page.items + + const request = yield* HttpServerRequest.HttpServerRequest + const url = 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, { + headers: { + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + Link: `<${url.toString()}>; rel="next"`, + "X-Next-Cursor": page.cursor, + }, + }) + })) }) const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - return yield* Effect.sync(() => - MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), + return yield* mapNotFound( + Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts new file mode 100644 index 000000000000..3ae091484f6e --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -0,0 +1,54 @@ +import { startWorkspaceSyncing } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Database } from "@/storage/db" +import { SyncEvent } from "@/sync" +import { EventTable } from "@/sync/event.sql" +import { asc } from "drizzle-orm" +import { and } from "drizzle-orm" +import { eq } from "drizzle-orm" +import { lte } from "drizzle-orm" +import { not } from "drizzle-orm" +import { or } from "drizzle-orm" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { HistoryPayload, ReplayPayload } from "../groups/sync" + +export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => + Effect.gen(function* () { + const start = Effect.fn("SyncHttpApi.start")(function* () { + startWorkspaceSyncing((yield* InstanceState.context).project.id) + return true + }) + + const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { + const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ + id: event.id, + aggregateID: event.aggregateID, + seq: event.seq, + type: event.type, + data: { ...event.data }, + })) + SyncEvent.replayAll(events) + return { sessionID: events[0].aggregateID } + }) + + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { + const exclude = Object.entries(ctx.payload) + return Database.use((db) => + db + .select() + .from(EventTable) + .where( + exclude.length > 0 + ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) + : undefined, + ) + .orderBy(asc(EventTable.seq)) + .all(), + ) + }) + + return handlers.handle("start", start).handle("replay", replay).handle("history", history) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts new file mode 100644 index 000000000000..cb12ccb7a704 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -0,0 +1,134 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { SessionTable } from "@/session/session.sql" +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 { InstanceHttpApi } from "../api" +import { CommandPayload, TuiPublishPayload } from "../groups/tui" + +const commandAliases = { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", +} as const + +export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => + Effect.gen(function* () { + const bus = yield* Bus.Service + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => + bus.publish(TuiEvent.CommandExecute, { command }) + + const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { + payload: typeof TuiEvent.PromptAppend.properties.Type + }) { + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + return true + }) + + const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { + yield* publishCommand("help.show") + return true + }) + + const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { + yield* publishCommand("session.list") + return true + }) + + const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { + yield* publishCommand("model.list") + return true + }) + + const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { + yield* publishCommand("prompt.submit") + return true + }) + + const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { + yield* publishCommand("prompt.clear") + return true + }) + + const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { + payload: typeof CommandPayload.Type + }) { + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + return true + }) + + const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { + payload: typeof TuiEvent.ToastShow.properties.Type + }) { + yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + return true + }) + + const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { + if (ctx.payload.type === TuiEvent.PromptAppend.type) + yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.CommandExecute.type) + yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.SessionSelect.type) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + return true + }) + + const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { + payload: typeof TuiEvent.SessionSelect.properties.Type + }) { + if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) + const row = yield* Effect.sync(() => + Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), + ), + ) + if (!row) return yield* new HttpApiError.NotFound({}) + yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + return true + }) + + const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { + return yield* Effect.promise(() => nextTuiRequest()) + }) + + const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { + submitTuiResponse(ctx.payload) + return true + }) + + return handlers + .handle("appendPrompt", appendPrompt) + .handle("openHelp", openHelp) + .handle("openSessions", openSessions) + .handle("openThemes", openThemes) + .handle("openModels", openModels) + .handle("submitPrompt", submitPrompt) + .handle("clearPrompt", clearPrompt) + .handle("executeCommand", executeCommand) + .handle("showToast", showToast) + .handle("publish", publish) + .handle("selectSession", selectSession) + .handle("controlNext", controlNext) + .handle("controlResponse", controlResponse) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts new file mode 100644 index 000000000000..9413c865d102 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -0,0 +1,66 @@ +import { listAdaptors } from "@/control-plane/adaptors" +import { Workspace } from "@/control-plane/workspace" +import * as InstanceState from "@/effect/instance-state" +import { Instance } from "@/project/instance" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../api" +import { CreatePayload, SessionRestorePayload } from "../groups/workspace" + +export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => + Effect.gen(function* () { + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => listAdaptors(instance.project.id)) + }) + + const list = Effect.fn("WorkspaceHttpApi.list")(function* () { + return Workspace.list((yield* InstanceState.context).project) + }) + + const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.create({ + ...ctx.payload, + projectID: instance.project.id, + }), + ), + ) + }) + + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { + const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) + return Workspace.status().filter((item) => ids.has(item.workspaceID)) + }) + + const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) + }) + + const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { + params: { id: Workspace.Info["id"] } + payload: typeof SessionRestorePayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + Workspace.sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }), + ), + ) + }) + + return handlers + .handle("adaptors", adaptors) + .handle("list", list) + .handle("create", create) + .handle("status", status) + .handle("remove", remove) + .handle("sessionRestore", sessionRestore) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts new file mode 100644 index 000000000000..1ad42c526189 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts @@ -0,0 +1,191 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { getAdaptor } from "@/control-plane/adaptors" +import { WorkspaceID } from "@/control-plane/schema" +import type { Target } from "@/control-plane/types" +import { Workspace } from "@/control-plane/workspace" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Instance } from "@/project/instance" +import { Session } from "@/session/session" +import { ServerProxy } from "@/server/proxy" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import { Filesystem } from "@/util/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Context, Effect, Layer } from "effect" +import type { unhandled } from "effect/Types" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" + +type HandlerEffect = Effect.Effect + +export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiInstanceContext", +) {} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function currentDirectory() { + try { + return Instance.directory + } catch { + return process.cwd() + } +} + +function sourceRequest(request: HttpServerRequest.HttpServerRequest) { + if (request.source instanceof Request) return request.source + return new Request(new URL(request.originalUrl, "http://localhost"), { + method: request.method, + headers: request.headers as HeadersInit, + }) +} + +function requestHeaders(request: HttpServerRequest.HttpServerRequest) { + return sourceRequest(request).headers +} + +function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, data: unknown) { + if (data instanceof Blob) { + void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) + return + } + if (typeof data === "string" || data instanceof Uint8Array) { + Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void))) + return + } + if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void))) +} + +function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) { + return Effect.gen(function* () { + const source = sourceRequest(request) + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + const queue: Array = [] + const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source)) + remote.binaryType = "arraybuffer" + remote.onopen = () => { + for (const item of queue) remote.send(item) + queue.length = 0 + } + remote.onmessage = (event) => writeSocket(write, event.data) + remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) + remote.onclose = (event) => + Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) + + yield* socket + .runRaw((message) => { + const data = typeof message === "string" ? message : message.slice() + if (remote.readyState === WebSocket.OPEN) { + remote.send(data) + return + } + queue.push(data) + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(Effect.sync(() => remote.close())), + Effect.orDie, + ) + return HttpServerResponse.empty() + }) +} + +function proxyRemote( + request: HttpServerRequest.HttpServerRequest, + workspace: Workspace.Info, + target: Extract, + requestURL: URL, +) { + const url = workspaceProxyURL(target.url, requestURL) + const source = sourceRequest(request) + if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) + return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw)) +} + +function requestContext() { + return Effect.withFiber((fiber) => + Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)), + ) +} + +function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) { + return Effect.gen(function* () { + const url = new URL(request.url, "http://localhost") + const headers = requestHeaders(request) + const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined + const workspaceParam = url.searchParams.get("workspace") + const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) + const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined + + if (workspaceID && !workspace && !envWorkspaceID) { + return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { + status: 500, + contentType: "text/plain; charset=utf-8", + }) + } + + if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) { + const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: target.directory, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, workspace.id), + ) + } + + const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory() + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(raw)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID), + ) + }) +} + +function provideInstanceContext(effect: HandlerEffect) { + return Effect.gen(function* () { + const request = yield* requestContext() + const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost")) + const session = sessionID + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.catchDefect(() => Effect.succeed(undefined)), + ) + : undefined + return yield* provideRequestContext(effect, request, session?.workspaceID) + }) +} + +export const instanceContextLayer = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), +) + +export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => + requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), +)).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 1916e426963b..e1c03e7bde38 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" const disposeAfterResponse = new WeakMap() -const reloadAfterResponse = new WeakMap[0] }>() export const markInstanceForDisposal = (ctx: InstanceContext) => HttpEffect.appendPreResponseHandler((request, response) => @@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => - HttpEffect.appendPreResponseHandler((request, response) => - Effect.sync(() => { - reloadAfterResponse.set(request.source, { ...ctx, next }) - return response - }), + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response), ) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => Effect.gen(function* () { const response = yield* effect const request = yield* HttpServerRequest.HttpServerRequest - const reload = reloadAfterResponse.get(request.source) - if (reload) { - reloadAfterResponse.delete(request.source) - yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next))) - return response - } - const ctx = disposeAfterResponse.get(request.source) if (!ctx) return response disposeAfterResponse.delete(request.source) - yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())) + yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts deleted file mode 100644 index 7dbc491e130c..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { ProviderAuth } from "@/provider/auth" -import { Config } from "@/config/config" -import { ModelsDev } from "@/provider/models" -import { Provider } from "@/provider/provider" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { Effect, Schema } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/provider" - -export const ProviderApi = HttpApi.make("provider") - .add( - HttpApiGroup.make("provider") - .add( - HttpApiEndpoint.get("list", root, { - success: Provider.ListResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.list", - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - }), - ), - HttpApiEndpoint.get("auth", `${root}/auth`, { - success: ProviderAuth.Methods, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.auth", - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - }), - ), - HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.AuthorizeInput, - success: Schema.UndefinedOr(ProviderAuth.Authorization), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.authorize", - summary: "Start OAuth authorization", - description: "Start the OAuth authorization flow for a provider.", - }), - ), - HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { - params: { providerID: ProviderID }, - payload: ProviderAuth.CallbackInput, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "provider.oauth.callback", - summary: "Handle OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "provider", - description: "Experimental HttpApi provider routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) => - Effect.gen(function* () { - const cfg = yield* Config.Service - const provider = yield* Provider.Service - const svc = yield* ProviderAuth.Service - - const list = Effect.fn("ProviderHttpApi.list")(function* () { - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }) - - const auth = Effect.fn("ProviderHttpApi.auth")(function* () { - return yield* svc.methods() - }) - - const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.AuthorizeInput - }) { - const result = yield* svc - .authorize({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return result - }) - - const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { - params: { providerID: ProviderID } - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( - Effect.mapError(() => new HttpApiError.BadRequest({})), - ) - const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) - }) - - const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.CallbackInput - }) { - yield* svc - .callback({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) - - return handlers - .handle("list", list) - .handle("auth", auth) - .handleRaw("authorize", authorizeRaw) - .handle("callback", callback) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts deleted file mode 100644 index d4e77c9d032f..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { EffectBridge } from "@/effect/bridge" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { Shell } from "@/shell/shell" -import { Effect, Schema } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" -import { Authorization } from "./auth" - -const root = "/pty" -const Params = Schema.Struct({ - ptyID: PtyID, -}) -const CursorQuery = Schema.Struct({ - cursor: Schema.optional(Schema.String), -}) -const ShellItem = Schema.Struct({ - path: Schema.String, - name: Schema.String, - acceptable: Schema.Boolean, -}) - -export const PtyPaths = { - shells: `${root}/shells`, - list: root, - create: root, - get: `${root}/:ptyID`, - update: `${root}/:ptyID`, - remove: `${root}/:ptyID`, - connect: `${root}/:ptyID/connect`, -} as const - -export const PtyApi = HttpApi.make("pty") - .add( - HttpApiGroup.make("pty") - .add( - HttpApiEndpoint.get("shells", PtyPaths.shells, { - success: Schema.Array(ShellItem), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.shells", - summary: "List available shells", - description: "Get a list of available shells on the system.", - }), - ), - HttpApiEndpoint.get("list", PtyPaths.list, { - success: Schema.Array(Pty.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.list", - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - }), - ), - HttpApiEndpoint.post("create", PtyPaths.create, { - payload: Pty.CreateInput, - success: Pty.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.create", - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - }), - ), - HttpApiEndpoint.get("get", PtyPaths.get, { - params: { ptyID: PtyID }, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.get", - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.put("update", PtyPaths.update, { - params: { ptyID: PtyID }, - payload: Pty.UpdateInput, - success: Pty.Info, - error: HttpApiError.NotFound, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.update", - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - }), - ), - HttpApiEndpoint.delete("remove", PtyPaths.remove, { - params: { ptyID: PtyID }, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.remove", - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "pty", - description: "Experimental HttpApi PTY routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const PtyConnectApi = HttpApi.make("pty-connect").add( - HttpApiGroup.make("pty-connect") - .add( - HttpApiEndpoint.get("connect", PtyPaths.connect, { - params: Params, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "pty.connect", - summary: "Connect to PTY session", - description: - "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), -) - -export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) => - Effect.gen(function* () { - const pty = yield* Pty.Service - - const shells = Effect.fn("PtyHttpApi.shells")(function* () { - return yield* Effect.promise(() => Shell.list()) - }) - - const list = Effect.fn("PtyHttpApi.list")(function* () { - return yield* pty.list() - }) - - const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - bridge.promise( - pty.create({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - ), - ) - }) - - const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { - const info = yield* pty.get(ctx.params.ptyID) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const update = Effect.fn("PtyHttpApi.update")(function* (ctx: { - params: { ptyID: PtyID } - payload: typeof Pty.UpdateInput.Type - }) { - const info = yield* pty.update(ctx.params.ptyID, { - ...ctx.payload, - size: ctx.payload.size ? { ...ctx.payload.size } : undefined, - }) - if (!info) return yield* new HttpApiError.NotFound({}) - return info - }) - - const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { - yield* pty.remove(ctx.params.ptyID) - return true - }) - - return handlers - .handle("shells", shells) - .handle("list", list) - .handle("create", create) - .handle("get", get) - .handle("update", update) - .handle("remove", remove) - }), -) - -export const ptyConnectRoute = HttpRouter.add( - "GET", - PtyPaths.connect, - Effect.gen(function* () { - const pty = yield* Pty.Service - const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - 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 write = yield* socket.writer - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - Effect.runFork( - write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), - ) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) - }, - } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() - - yield* socket - .runRaw((message) => { - handler.onMessage(typeof message === "string" ? message : message.slice().buffer) - }) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { - closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) - return HttpServerResponse.empty() - }).pipe(Effect.provide(Pty.defaultLayer)), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index a4e86e9a5f22..d9871c69be00 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,21 +1,5 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { ConfigApi } from "./config" -import { ControlApi } from "./control" -import { EventApi } from "./event" -import { ExperimentalApi } from "./experimental" -import { FileApi } from "./file" -import { GlobalApi } from "./global" -import { InstanceApi } from "./instance" -import { McpApi } from "./mcp" -import { PermissionApi } from "./permission" -import { ProjectApi } from "./project" -import { ProviderApi } from "./provider" -import { PtyApi, PtyConnectApi } from "./pty" -import { QuestionApi } from "./question" -import { SessionApi } from "./session" -import { SyncApi } from "./sync" -import { TuiApi } from "./tui" -import { WorkspaceApi } from "./workspace" +import { OpenApi } from "effect/unstable/httpapi" +import { OpenCodeHttpApi } from "./api" type OpenApiParameter = { name: string @@ -26,11 +10,12 @@ type OpenApiParameter = { type OpenApiOperation = { parameters?: OpenApiParameter[] - responses?: Record + responses?: Record requestBody?: { required?: boolean content?: Record } + security?: unknown } type OpenApiPathItem = Partial> @@ -38,6 +23,7 @@ type OpenApiPathItem = Partial + securitySchemes?: Record } paths?: Record } @@ -47,16 +33,25 @@ type OpenApiSchema = { additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] - enum?: string[] + description?: string + enum?: Array items?: OpenApiSchema maximum?: number minimum?: number oneOf?: OpenApiSchema[] prefixItems?: OpenApiSchema[] properties?: Record + required?: string[] type?: string } +type OpenApiResponse = { + description?: string + content?: Record +} + +// Instance routes use middleware for directory/workspace resolution, but HttpApi +// doesn't surface middleware query params in the spec. Inject them explicitly. const InstanceQueryParameters = [ { name: "directory", @@ -72,8 +67,9 @@ const InstanceQueryParameters = [ }, ] satisfies OpenApiParameter[] -const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"]) -const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"]) +// Query schemas describe decoded Effect values, but the generated SDK needs the +// public call shape. These keep SDK callers passing numbers/booleans while the +// server still decodes string query params at runtime. const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { @@ -81,60 +77,80 @@ const QueryParameterSchemas = { "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const LegacyComponentDescriptions = { + LogLevel: "Log level", + ServerConfig: "Server configuration for opencode serve and web commands", + LayoutConfig: "@deprecated Always uses stretch layout.", +} satisfies Record + function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec + + // Effect's multi-document JSON Schema deduplicator can produce self-referencing + // component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition + // of X itself) when the same AST node appears both as a standalone endpoint + // payload and inside an annotated union arm. Resolve these by inlining the + // actual schema from any parent union that references them. + fixSelfReferencingComponents(spec) + + // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, + // but the legacy SDK expected plain `T` for optional fields. Strip null + // from all component schemas so both request and response types match. + for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { + spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema)) + } + normalizeComponentNames(spec) + collapseDuplicateComponents(spec) + applyLegacySchemaOverrides(spec) + normalizeComponentDescriptions(spec) + addLegacyErrorSchemas(spec) + delete spec.components?.schemas?.Unauthorized + delete spec.components?.schemas?.EffectHttpApiErrorBadRequest + delete spec.components?.schemas?.EffectHttpApiErrorNotFound + delete spec.components?.schemas?.effect_HttpApiError_BadRequest + delete spec.components?.schemas?.effect_HttpApiError_NotFound + delete spec.components?.securitySchemes + for (const [path, item] of Object.entries(spec.paths ?? {})) { const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue if (operation.requestBody) { + // Hono's generated OpenAPI never marked request bodies as required. Keep + // that SDK surface stable during the HttpApi migration. delete operation.requestBody.required - for (const media of Object.values(operation.requestBody.content ?? {})) { - const ref = media.schema?.$ref?.replace("#/components/schemas/", "") - if (ref && LegacyBodyRefParameters.has(ref)) continue - if (ref && spec.components?.schemas?.[ref]) { - media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref])) - continue - } - if (media.schema) media.schema = normalizeRequestSchema(media.schema) - } + const body = operation.requestBody.content?.["application/json"] + if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema)) if (path === "/experimental/workspace" && method === "post") { - const properties = operation.requestBody.content?.["application/json"]?.schema?.properties + // Workspace creation fields `branch` and `extra` are Schema.NullOr — + // genuinely nullable, not just optional. Re-add the null that the + // component-level strip above removed. + const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "") + const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } - if (path === "/tui/publish" && method === "post" && spec.components?.schemas) { - const schema = operation.requestBody.content?.["application/json"]?.schema - const anyOf = schema?.anyOf - if (anyOf?.length === 4) { - spec.components.schemas.EventTuiPromptAppend = anyOf[0] - spec.components.schemas.EventTuiCommandExecute = anyOf[1] - spec.components.schemas.EventTuiToastShow = anyOf[2] - spec.components.schemas.EventTuiSessionSelect = anyOf[3] - operation.requestBody.content!["application/json"]!.schema = { - anyOf: [ - { $ref: "#/components/schemas/EventTuiPromptAppend" }, - { $ref: "#/components/schemas/EventTuiCommandExecute" }, - { $ref: "#/components/schemas/EventTuiToastShow" }, - { $ref: "#/components/schemas/EventTuiSessionSelect" }, - ], - } - } - } - if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) { - const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events - if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") { - events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent)) - } + } + for (const response of Object.values(operation.responses ?? {})) { + for (const content of Object.values(response.content ?? {})) { + if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema)) } } + // Hono applied auth as runtime middleware outside OpenAPI metadata, so the + // legacy SDK did not expose auth schemes or generated 401 error unions. + delete operation.security + delete operation.responses?.["401"] + normalizeLegacyErrorResponses(operation) + normalizeLegacyOperation(operation, path, method) if ((path === "/event" || path === "/global/event") && method === "get") { + // HttpApi has no first-class SSE response schema, and these handlers are + // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { description: "Event stream", content: { "text/event-stream": { - schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" }, + schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" }, }, }, } @@ -152,40 +168,302 @@ function matchLegacyOpenApi(input: Record) { return input } -function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema { +function addLegacyErrorSchemas(spec: OpenApiSpec) { + if (!spec.components?.schemas) return + spec.components.schemas.BadRequestError = { + type: "object", + required: ["data", "errors", "success"], + properties: { + data: {}, + errors: { + type: "array", + items: { + type: "object", + additionalProperties: {}, + }, + }, + success: { type: "boolean", enum: [false] }, + }, + } + spec.components.schemas.NotFoundError = { + type: "object", + required: ["name", "data"], + properties: { + name: { type: "string", enum: ["NotFoundError"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + }, + }, + }, + } +} + +function collapseDuplicateComponents(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + for (const name of Object.keys(schemas)) { + const base = name.replace(/\d+$/, "") + if (base === name || !schemas[base]) continue + if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue + rewriteRefs(spec, name, base) + delete schemas[name] + } +} + +function normalizeComponentNames(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + for (const name of Object.keys(schemas)) { + const next = componentTypeName(name) + if (next === name) continue + if (schemas[next]) { + if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) { + rewriteRefs(spec, name, next) + delete schemas[name] + } + continue + } + schemas[next] = schemas[name] + rewriteRefs(spec, name, next) + delete schemas[name] + } +} + +function componentTypeName(name: string) { + if (!name.includes(".")) return name + return name + .split(".") + .filter((part) => !/^\d+$/.test(part)) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join("") +} + +function applyLegacySchemaOverrides(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {} + if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" } + if (schemas.Workspace?.properties) { + schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch) + schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory) + schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra) + } + if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project) + const providerOptions = schemas.ProviderConfig?.properties?.options + if (providerOptions) providerOptions.additionalProperties = {} + const model = schemas.ProviderConfig?.properties?.models?.additionalProperties + const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined + if (variants && typeof variants === "object") variants.additionalProperties = {} + const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info + if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties) +} + +function normalizeComponentDescriptions(spec: OpenApiSpec) { + for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { + const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + if (description) { + schema.description = description + continue + } + delete schema.description + } +} + +function makePropertiesNullable(properties: Record) { + for (const [key, value] of Object.entries(properties)) { + if (key === "share" && value.properties?.url) { + value.properties.url = nullable(value.properties.url) + continue + } + if (key === "time" && value.properties) { + makePropertiesNullable(value.properties) + continue + } + properties[key] = nullable(value) + } +} + +function nullable(schema: OpenApiSchema): OpenApiSchema { + if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema + return { anyOf: [schema, { type: "null" }] } +} + +function stableSchema(input: unknown, schemas: Record): string { + return JSON.stringify(canonicalizeSchema(input, schemas)) +} + +function canonicalizeSchema(input: unknown, schemas: Record): unknown { + if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas)) + if (!input || typeof input !== "object") return input + const schema = input as OpenApiSchema + if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) } + return Object.fromEntries( + Object.entries(input) + .filter(([key]) => key !== "description") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, canonicalizeSchema(value, schemas)]), + ) +} + +function canonicalRef(ref: string, schemas: Record) { + const name = ref.replace("#/components/schemas/", "") + const base = name.replace(/\d+$/, "") + if (base !== name && schemas[base]) return `#/components/schemas/${base}` + return ref +} + +function rewriteRefs(input: unknown, from: string, to: string): void { + if (Array.isArray(input)) { + for (const item of input) rewriteRefs(item, from, to) + return + } + if (!input || typeof input !== "object") return + const schema = input as OpenApiSchema + if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}` + for (const value of Object.values(input)) rewriteRefs(value, from, to) +} + +function normalizeLegacyErrorResponses(operation: OpenApiOperation) { + if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) { + operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError") + } + if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) { + operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError") + } +} + +function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) { + if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"] + if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"] + if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return + const response = operation.responses?.["200"]?.content?.["application/json"] + if (!response) return + response.schema = { + type: "object", + required: ["info", "parts"], + properties: { + info: { $ref: "#/components/schemas/AssistantMessage" }, + parts: { + type: "array", + items: { $ref: "#/components/schemas/Part" }, + }, + }, + } +} + +function isRefResponse(response: OpenApiResponse, name: string) { + return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}` +} + +function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") { + return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`) +} + +function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse { + return { + description, + content: { + "application/json": { + schema: { $ref: `#/components/schemas/${name}` }, + }, + }, + } +} + +/** + * Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI + * generation bug where annotated union arms that share AST nodes with other + * endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X. + * + * Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf` + * that references the broken component, then inlining that schema. + */ +function fixSelfReferencingComponents(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas) return + const selfRefs = new Set() + for (const [name, schema] of Object.entries(schemas)) { + if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name) + } + if (selfRefs.size === 0) return + // Find a parent union component whose anyOf/oneOf contains a $ref to the + // broken component — that parent was generated correctly and holds the inline + // schema we need. + for (const [, schema] of Object.entries(schemas)) { + for (const member of schema.anyOf ?? schema.oneOf ?? []) { + const ref = member.$ref?.replace("#/components/schemas/", "") + if (!ref || !selfRefs.has(ref)) continue + // This member's $ref points to a self-referencing component. The member + // itself is just {$ref:...}, so the actual schema must be resolved from + // the union. Since the union component was generated before the + // deduplicator broke things, the inline version lives elsewhere. Generate + // a fresh spec without the transform to get the correct schema. + // Simpler approach: look through all paths for an endpoint that uses this + // schema as a payload (it would have been expanded by the ref-expansion + // logic above if we ran after that, but we run before). Instead, just + // delete the broken component — if it's referenced via $ref elsewhere, + // the ref expansion in the request body loop will inline it anyway. + } + } + // Simplest fix: generate the raw spec (without transform) to get correct schemas + const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const rawSchemas = raw.components?.schemas + if (!rawSchemas) return + for (const name of selfRefs) { + if (rawSchemas[name]) schemas[name] = rawSchemas[name] + } +} + +/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ +function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { const withoutNull = options.filter((item) => item.type !== "null") - const finite = withoutNull.find((item) => item.type === "number") - if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" } - if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0]) - if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema) - if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema) + if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0]) + if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull) + if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull) } if (schema.allOf) { - if (schema.type) delete schema.allOf - else schema.allOf = schema.allOf.map(normalizeRequestSchema) + const allOf = schema.allOf.map(stripOptionalNull) + if (schema.type) { + delete schema.allOf + for (const item of allOf) Object.assign(schema, item) + } else { + schema.allOf = allOf + } } if (schema.prefixItems && schema.items) delete schema.prefixItems - if (schema.items) schema.items = normalizeRequestSchema(schema.items) + if (schema.items) schema.items = stripOptionalNull(schema.items) if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { - schema.properties[key] = normalizeRequestSchema(value) + schema.properties[key] = stripOptionalNull(value) } } if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties) + schema.additionalProperties = stripOptionalNull(schema.additionalProperties) } return schema } -function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { - return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) +function isEmptyObjectUnion(schema: OpenApiSchema) { + const options = schema.anyOf ?? schema.oneOf + return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema) +} + +function isBareObjectSchema(schema: OpenApiSchema) { + return schema.type === "object" && !schema.properties && !schema.additionalProperties +} + +function isBareArraySchema(schema: OpenApiSchema) { + return schema.type === "array" && !schema.items && !schema.prefixItems } -function isFiniteNumberOption(schema: OpenApiSchema) { - if (schema.type === "number") return true - return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true +function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { + return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) } function normalizeParameter(param: OpenApiParameter, route: string) { @@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } return } - param.schema = normalizeRequestSchema(param.schema) -} - -export const PublicApi = HttpApi.make("opencode") - .addHttpApi(ControlApi) - .addHttpApi(GlobalApi) - .addHttpApi(EventApi) - .addHttpApi(ConfigApi) - .addHttpApi(ExperimentalApi) - .addHttpApi(FileApi) - .addHttpApi(InstanceApi) - .addHttpApi(McpApi) - .addHttpApi(PermissionApi) - .addHttpApi(ProjectApi) - .addHttpApi(ProviderApi) - .addHttpApi(PtyApi) - .addHttpApi(PtyConnectApi) - .addHttpApi(QuestionApi) - .addHttpApi(SessionApi) - .addHttpApi(SyncApi) - .addHttpApi(TuiApi) - .addHttpApi(WorkspaceApi) + param.schema = stripOptionalNull(param.schema) +} + +export const PublicApi = OpenCodeHttpApi .annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e96c21b55bc7..2f4bde918399 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,14 +1,12 @@ -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" import { Bus } from "@/bus" import { Config } from "@/config/config" import { Command } from "@/command" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" import { Ripgrep } from "@/file/ripgrep" @@ -16,8 +14,6 @@ import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" import { Installation } from "@/installation" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -32,131 +28,103 @@ import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { InstanceHttpApi, RootHttpApi } from "./api" import { authorizationLayer } from "./auth" -import { ConfigApi, configHandlers } from "./config" -import { ControlApi, controlHandlers } from "./control" import { eventRoute } from "./event" -import { FileApi, fileHandlers } from "./file" -import { ExperimentalApi, experimentalHandlers } from "./experimental" -import { GlobalApi, globalHandlers } from "./global" -import { InstanceApi, instanceHandlers } from "./instance" -import { McpApi, mcpHandlers } from "./mcp" -import { PermissionApi, permissionHandlers } from "./permission" -import { ProjectApi, projectHandlers } from "./project" -import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty" -import { ProviderApi, providerHandlers } from "./provider" -import { QuestionApi, questionHandlers } from "./question" -import { SessionApi, sessionHandlers } from "./session" -import { SyncApi, syncHandlers } from "./sync" -import { TuiApi, tuiHandlers } from "./tui" -import { WorkspaceApi, workspaceHandlers } from "./workspace" +import { configHandlers } from "./handlers/config" +import { controlHandlers } from "./handlers/control" +import { experimentalHandlers } from "./handlers/experimental" +import { fileHandlers } from "./handlers/file" +import { globalHandlers } from "./handlers/global" +import { instanceHandlers } from "./handlers/instance" +import { mcpHandlers } from "./handlers/mcp" +import { permissionHandlers } from "./handlers/permission" +import { projectHandlers } from "./handlers/project" +import { providerHandlers } from "./handlers/provider" +import { ptyConnectRoute, ptyHandlers } from "./handlers/pty" +import { questionHandlers } from "./handlers/question" +import { sessionHandlers } from "./handlers/session" +import { syncHandlers } from "./handlers/sync" +import { tuiHandlers } from "./handlers/tui" +import { workspaceHandlers } from "./handlers/workspace" +import { instanceContextLayer, instanceRouterLayer } from "./instance-context" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" - -const Query = Schema.Struct({ - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - auth_token: Schema.optional(Schema.String), -}) - -const Headers = Schema.Struct({ - authorization: Schema.optional(Schema.String), - "x-opencode-directory": Schema.optional(Schema.String), -}) +import * as ServerBackend from "@/server/backend" export const context = Context.empty() as Context.Context -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -const instance = HttpRouter.middleware()( - Effect.gen(function* () { - return (effect) => - Effect.gen(function* () { - const query = yield* HttpServerRequest.schemaSearchParams(Query) - const headers = yield* HttpServerRequest.schemaHeaders(Headers) - const raw = query.directory || headers["x-opencode-directory"] || process.cwd() - const workspace = query.workspace || undefined - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect - return yield* next.pipe(Effect.provideService(InstanceRef, ctx)) - }) - }), +const runtime = HttpRouter.middleware()( + Effect.succeed((effect) => + Effect.gen(function* () { + const selected = ServerBackend.select() + yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi"))) + return yield* effect + }), + ), ).layer -const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers)) -const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers)) -const instanceApiRoutes = Layer.mergeAll( - HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)), - HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)), - HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)), - HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)), - HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)), - HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)), - HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)), - HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)), - HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)), - HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)), - HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)), - HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)), - HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)), - HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)), +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( + Layer.provide([ + configHandlers, + experimentalHandlers, + fileHandlers, + instanceHandlers, + mcpHandlers, + projectHandlers, + ptyHandlers, + questionHandlers, + permissionHandlers, + providerHandlers, + sessionHandlers, + syncHandlers, + tuiHandlers, + workspaceHandlers, + ]), ) -const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe( - Layer.provide(authorizationLayer), - Layer.provide(instance), +const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) +const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( + Layer.provide([authorizationLayer, instanceContextLayer]), ) -export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes) - .pipe( - Layer.provide(Account.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(File.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Installation.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(ProviderAuth.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Pty.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Session.defaultLayer), - ) - .pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Worktree.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(HttpServer.layerServices), - Layer.provideMerge(Observability.layer), - ) +export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( + Layer.provide([ + runtime, + Account.defaultLayer, + Agent.defaultLayer, + Auth.defaultLayer, + Command.defaultLayer, + Config.defaultLayer, + File.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Installation.defaultLayer, + MCP.defaultLayer, + Permission.defaultLayer, + Project.defaultLayer, + ProviderAuth.defaultLayer, + Provider.defaultLayer, + Pty.defaultLayer, + Question.defaultLayer, + Ripgrep.defaultLayer, + Session.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, + SessionSummary.defaultLayer, + Skill.defaultLayer, + Todo.defaultLayer, + ToolRegistry.defaultLayer, + Vcs.defaultLayer, + Worktree.defaultLayer, + Bus.layer, + HttpServer.layerServices, + ]), + Layer.provideMerge(Observability.layer), +) export const webHandler = lazy(() => HttpRouter.toWebHandler(routes, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts deleted file mode 100644 index 67fcede2f8cd..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { startWorkspaceSyncing } from "@/control-plane/workspace" -import * as InstanceState from "@/effect/instance-state" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { and } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { lte } from "drizzle-orm" -import { not } from "drizzle-orm" -import { or } from "drizzle-orm" -import { SyncEvent } from "@/sync" -import { EventTable } from "@/sync/event.sql" -import { NonNegativeInt } from "@/util/schema" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/sync" -const ReplayEvent = Schema.Struct({ - id: Schema.String, - aggregateID: Schema.String, - seq: NonNegativeInt, - type: Schema.String, - data: Schema.Record(Schema.String, Schema.Unknown), -}).annotate({ identifier: "SyncReplayEvent" }) -const ReplayPayload = Schema.Struct({ - directory: Schema.String, - events: Schema.NonEmptyArray(ReplayEvent), -}).annotate({ identifier: "SyncReplayInput" }) -const ReplayResponse = Schema.Struct({ - sessionID: Schema.String, -}).annotate({ identifier: "SyncReplayResponse" }) -const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) -const HistoryEvent = Schema.Struct({ - id: Schema.String, - aggregate_id: Schema.String, - seq: Schema.Number, - type: Schema.String, - data: Schema.Record(Schema.String, Schema.Unknown), -}).annotate({ identifier: "SyncHistoryEvent" }) - -export const SyncPaths = { - start: `${root}/start`, - replay: `${root}/replay`, - history: `${root}/history`, -} as const - -export const SyncApi = HttpApi.make("sync") - .add( - HttpApiGroup.make("sync") - .add( - HttpApiEndpoint.post("start", SyncPaths.start, { - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.start", - summary: "Start workspace sync", - description: "Start sync loops for workspaces in the current project that have active sessions.", - }), - ), - HttpApiEndpoint.post("replay", SyncPaths.replay, { - payload: ReplayPayload, - success: ReplayResponse, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.replay", - summary: "Replay sync events", - description: "Validate and replay a complete sync event history.", - }), - ), - HttpApiEndpoint.post("history", SyncPaths.history, { - payload: HistoryPayload, - success: Schema.Array(HistoryEvent), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.history.list", - 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.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "sync", - description: "Experimental HttpApi sync routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) => - Effect.gen(function* () { - const start = Effect.fn("SyncHttpApi.start")(function* () { - startWorkspaceSyncing((yield* InstanceState.context).project.id) - return true - }) - - const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) { - const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({ - id: event.id, - aggregateID: event.aggregateID, - seq: event.seq, - type: event.type, - data: { ...event.data }, - })) - SyncEvent.replayAll(events) - return { sessionID: events[0].aggregateID } - }) - - const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { - const exclude = Object.entries(ctx.payload) - return Database.use((db) => - db - .select() - .from(EventTable) - .where( - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined, - ) - .orderBy(asc(EventTable.seq)) - .all(), - ) - }) - - return handlers.handle("start", start).handle("replay", replay).handle("history", history) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts deleted file mode 100644 index 2bcc740ddde6..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { Bus } from "@/bus" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { SessionID } from "@/session/schema" -import { SessionTable } from "@/session/session.sql" -import * as Database from "@/storage/db" -import { eq } from "drizzle-orm" -import { Effect, Schema } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../tui" -import { Authorization } from "./auth" - -const root = "/tui" -const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" }) -const TuiRequestPayload = Schema.Struct({ - path: Schema.String, - body: Schema.Unknown, -}).annotate({ identifier: "TuiRequest" }) -const TuiPublishPayload = Schema.Union([ - Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }), - Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }), -]).annotate({ identifier: "TuiEventInput" }) - -const commandAliases = { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", -} as const - -export const TuiPaths = { - appendPrompt: `${root}/append-prompt`, - openHelp: `${root}/open-help`, - openSessions: `${root}/open-sessions`, - openThemes: `${root}/open-themes`, - openModels: `${root}/open-models`, - submitPrompt: `${root}/submit-prompt`, - clearPrompt: `${root}/clear-prompt`, - executeCommand: `${root}/execute-command`, - showToast: `${root}/show-toast`, - publish: `${root}/publish`, - selectSession: `${root}/select-session`, - controlNext: `${root}/control/next`, - controlResponse: `${root}/control/response`, -} as const - -export const TuiApi = HttpApi.make("tui") - .add( - HttpApiGroup.make("tui") - .add( - HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { - payload: TuiEvent.PromptAppend.properties, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.appendPrompt", - summary: "Append TUI prompt", - description: "Append prompt to the TUI.", - }), - ), - HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openHelp", - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - }), - ), - HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openSessions", - summary: "Open sessions dialog", - description: "Open the session dialog.", - }), - ), - HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openThemes", - summary: "Open themes dialog", - description: "Open the theme dialog.", - }), - ), - HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.openModels", - summary: "Open models dialog", - description: "Open the model dialog.", - }), - ), - HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.submitPrompt", - summary: "Submit TUI prompt", - description: "Submit the prompt.", - }), - ), - HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.clearPrompt", - summary: "Clear TUI prompt", - description: "Clear the prompt.", - }), - ), - HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { - payload: CommandPayload, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.executeCommand", - summary: "Execute TUI command", - description: "Execute a TUI command.", - }), - ), - HttpApiEndpoint.post("showToast", TuiPaths.showToast, { - payload: TuiEvent.ToastShow.properties, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.showToast", - summary: "Show TUI toast", - description: "Show a toast notification in the TUI.", - }), - ), - HttpApiEndpoint.post("publish", TuiPaths.publish, { - payload: TuiPublishPayload, - success: Schema.Boolean, - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.publish", - summary: "Publish TUI event", - description: "Publish a TUI event.", - }), - ), - HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { - payload: TuiEvent.SessionSelect.properties, - success: Schema.Boolean, - error: [HttpApiError.BadRequest, HttpApiError.NotFound], - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.selectSession", - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - }), - ), - HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.control.next", - summary: "Get next TUI request", - description: "Retrieve the next TUI request from the queue for processing.", - }), - ), - HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { - payload: Schema.Unknown, - success: Schema.Boolean, - }).annotateMerge( - OpenApi.annotations({ - identifier: "tui.control.response", - summary: "Submit TUI response", - description: "Submit a response to the TUI request queue to complete a pending request.", - }), - ), - ) - .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) => - Effect.gen(function* () { - const bus = yield* Bus.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => - bus.publish(TuiEvent.CommandExecute, { command }) - - const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { - payload: typeof TuiEvent.PromptAppend.properties.Type - }) { - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) - return true - }) - - const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () { - yield* publishCommand("help.show") - return true - }) - - const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () { - yield* publishCommand("session.list") - return true - }) - - const openModels = Effect.fn("TuiHttpApi.openModels")(function* () { - yield* publishCommand("model.list") - return true - }) - - const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () { - yield* publishCommand("prompt.submit") - return true - }) - - const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () { - yield* publishCommand("prompt.clear") - return true - }) - - const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { - payload: typeof CommandPayload.Type - }) { - yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) - return true - }) - - const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { - payload: typeof TuiEvent.ToastShow.properties.Type - }) { - yield* bus.publish(TuiEvent.ToastShow, ctx.payload) - return true - }) - - const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { - if (ctx.payload.type === TuiEvent.PromptAppend.type) - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.CommandExecute.type) - yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.SessionSelect.type) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) - return true - }) - - const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { - payload: typeof TuiEvent.SessionSelect.properties.Type - }) { - const row = yield* Effect.sync(() => - Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(), - ), - ) - if (!row) return yield* new HttpApiError.NotFound({}) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) - return true - }) - - const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () { - return yield* Effect.promise(() => nextTuiRequest()) - }) - - const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) { - submitTuiResponse(ctx.payload) - return true - }) - - return handlers - .handle("appendPrompt", appendPrompt) - .handle("openHelp", openHelp) - .handle("openSessions", openSessions) - .handle("openThemes", openThemes) - .handle("openModels", openModels) - .handle("submitPrompt", submitPrompt) - .handle("clearPrompt", clearPrompt) - .handle("executeCommand", executeCommand) - .handle("showToast", showToast) - .handle("publish", publish) - .handle("selectSession", selectSession) - .handle("controlNext", controlNext) - .handle("controlResponse", controlResponse) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts deleted file mode 100644 index 1c5b4f87d806..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { listAdaptors } from "@/control-plane/adaptors" -import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" -import * as InstanceState from "@/effect/instance-state" -import { Instance } from "@/project/instance" -import { Effect, Schema, Struct } from "effect" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "./auth" - -const root = "/experimental/workspace" -const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({ - identifier: "WorkspaceCreateInput", -}) -const SessionRestorePayload = Schema.Struct( - Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), -).annotate({ - identifier: "WorkspaceSessionRestoreInput", -}) -const SessionRestoreResponse = Schema.Struct({ - total: Schema.Number, -}).annotate({ identifier: "WorkspaceSessionRestoreResponse" }) - -export const WorkspacePaths = { - adaptors: `${root}/adaptor`, - list: root, - status: `${root}/status`, - remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, -} as const - -export const WorkspaceApi = HttpApi.make("workspace") - .add( - HttpApiGroup.make("workspace") - .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: Schema.Array(WorkspaceAdaptorEntry), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - }), - ), - HttpApiEndpoint.get("list", WorkspacePaths.list, { - success: Schema.Array(Workspace.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.list", - summary: "List workspaces", - description: "List all workspaces.", - }), - ), - HttpApiEndpoint.post("create", WorkspacePaths.list, { - payload: CreatePayload, - success: Workspace.Info, - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.create", - summary: "Create workspace", - description: "Create a workspace for the current project.", - }), - ), - HttpApiEndpoint.get("status", WorkspacePaths.status, { - success: Schema.Array(Workspace.ConnectionStatus), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.status", - summary: "Workspace status", - description: "Get connection status for workspaces in the current project.", - }), - ), - HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { - params: { id: Workspace.Info.fields.id }, - success: Schema.UndefinedOr(Workspace.Info), - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.remove", - summary: "Remove workspace", - description: "Remove an existing workspace.", - }), - ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: SessionRestoreResponse, - }).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.", - }), - ), - ) - .annotateMerge( - OpenApi.annotations({ - title: "workspace", - description: "Experimental HttpApi workspace routes.", - }), - ) - .middleware(Authorization), - ) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) - -export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) => - Effect.gen(function* () { - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { - const ctx = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(ctx.project.id)) - }) - - const list = Effect.fn("WorkspaceHttpApi.list")(function* () { - return Workspace.list((yield* InstanceState.context).project) - }) - - const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.create({ - ...ctx.payload, - projectID: instance.project.id, - }), - ), - ) - }) - - const status = Effect.fn("WorkspaceHttpApi.status")(function* () { - const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) - return Workspace.status().filter((item) => ids.has(item.workspaceID)) - }) - - const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) - }) - - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ), - ) - }) - - return handlers - .handle("adaptors", adaptors) - .handle("list", list) - .handle("create", create) - .handle("status", status) - .handle("remove", remove) - .handle("sessionRestore", sessionRestore) - }), -) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 92d844fbfec4..40e709edd480 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -17,6 +17,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 * as ServerBackend from "./backend" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -37,13 +38,38 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } -const DefaultHono = lazy(() => createHono({})) -const DefaultHttpApi = lazy(() => createHttpApi()) -export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono()) +const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" }))) +const DefaultHttpApi = lazy(() => createDefaultHttpApi()) + +function select() { + return ServerBackend.select() +} + +export const backend = select + +export const Default = () => { + const selected = select() + return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() +} function create(opts: { cors?: string[] }) { - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi() - return createHono(opts) + const selected = select() + return selected.backend === "effect-httpapi" + ? withBackend(selected, createHttpApi()) + : withBackend(selected, createHono(opts, selected)) +} + +export function Legacy(opts: { cors?: string[] } = {}) { + return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) +} + +function createDefaultHttpApi() { + return withBackend(select(), createHttpApi()) +} + +function withBackend(selection: ServerBackend.Selection, built: T) { + log.info("server backend selected", ServerBackend.attributes(selection)) + return built } function createHttpApi() { @@ -60,11 +86,12 @@ function createHttpApi() { } } -function createHono(opts: { cors?: string[] }) { +function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { + const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) .use(AuthMiddleware) - .use(LoggerMiddleware) + .use(LoggerMiddleware(backendAttributes)) .use(CompressionMiddleware) .use(CorsMiddleware(opts)) .route("/global", GlobalRoutes()) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 5117fb8fa938..29b1ab986909 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -21,7 +21,7 @@ const RULES: Array = [ { method: "GET", path: "/session", action: "local" }, ] -function local(method: string, path: string) { +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 + "/") @@ -30,7 +30,7 @@ function local(method: string, path: string) { return false } -function getSessionID(url: URL) { +export function getWorkspaceRouteSessionID(url: URL) { if (url.pathname === "/session/status") return null const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] @@ -39,8 +39,17 @@ function getSessionID(url: URL) { 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 +} + async function getSessionWorkspace(url: URL) { - const id = getSessionID(url) + const id = getWorkspaceRouteSessionID(url) if (!id) return null const session = await AppRuntime.runPromise( @@ -73,7 +82,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - if (local(c.req.method, url.pathname)) { + if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { // No instance provided because we are serving cached data; there // is no instance to work with return next() @@ -96,11 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const proxyURL = new URL(target.url) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${url.pathname}` - proxyURL.search = url.search - proxyURL.hash = url.hash - proxyURL.searchParams.delete("workspace") + const proxyURL = workspaceProxyURL(target.url, url) log.info("workspace proxy forwarding", { workspaceID, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6b64b02319a8..b1a6ff403633 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -42,7 +42,7 @@ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {} export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = namedSchemaError("StructuredOutputError", { message: Schema.String, - retries: Schema.Number, + retries: NonNegativeInt, }) export const AuthError = namedSchemaError("ProviderAuthError", { providerID: Schema.String, @@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", { }) export const APIError = namedSchemaError("APIError", { message: Schema.String, - statusCode: Schema.optional(Schema.Number), + statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean, responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseBody: Schema.optional(Schema.String), @@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({ ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), @@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({ text: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), }) .annotate({ identifier: "ReasoningPart" }) @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable ({ zod: zod(s) }))) @@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Int, - end: Schema.Int, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }) @@ -242,11 +242,10 @@ export type SubtaskPart = Types.DeepMutable +// Effect Schema for the same union — used by HttpApi OpenAPI generation. +const AssistantErrorSchema = Schema.Union([ + AuthError.EffectSchema, + Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }), + OutputLengthError.EffectSchema, + AbortedError.EffectSchema, + StructuredOutputError.EffectSchema, + ContextOverflowError.EffectSchema, + APIError.EffectSchema, +]).annotate({ discriminator: "name" }) + // ── Prompt input schemas ───────────────────────────────────────────────────── // // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the @@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({ ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ - start: Schema.Number, - end: Schema.optional(Schema.Number), + start: NonNegativeInt, + end: Schema.optional(NonNegativeInt), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), @@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({ source: Schema.optional( Schema.Struct({ value: Schema.String, - start: Schema.Int, - end: Schema.Int, + start: NonNegativeInt, + end: NonNegativeInt, }), ), }) @@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({ ...messageBase, role: Schema.Literal("assistant"), time: Schema.Struct({ - created: Schema.Number, - completed: Schema.optional(Schema.Number), + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), }), - error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), + error: Schema.optional(AssistantErrorSchema), parentID: MessageID, modelID: ModelID, providerID: ProviderID, @@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({ root: Schema.String, }), summary: Schema.optional(Schema.Boolean), - cost: Schema.Number, + cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(Schema.Number), - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + total: Schema.optional(NonNegativeInt), + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), structured: Schema.optional(Schema.Any), @@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({ const PartUpdatedEventSchema = Schema.Struct({ sessionID: SessionID, part: _Part, - time: Schema.Number, + time: NonNegativeInt, }) const PartRemovedEventSchema = Schema.Struct({ @@ -651,7 +659,7 @@ export type WithParts = { const Cursor = Schema.Struct({ id: MessageID, - time: Schema.Number, + time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), }) type Cursor = typeof Cursor.Type diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index b1b245343162..9d67c48686cc 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({ export const ToolCall = Schema.Struct({ state: Schema.Literal("call"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type export const ToolPartialCall = Schema.Struct({ state: Schema.Literal("partial-call"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ state: Schema.Literal("result"), - step: Schema.optional(Schema.Number), + step: Schema.optional(NonNegativeInt), toolCallId: Schema.String, toolName: Schema.String, args: Schema.Unknown, @@ -141,8 +141,8 @@ export const Info = Schema.Struct({ parts: Schema.Array(MessagePart), metadata: Schema.Struct({ time: Schema.Struct({ - created: Schema.Number, - completed: Schema.optional(Schema.Number), + created: NonNegativeInt, + completed: Schema.optional(NonNegativeInt), }), error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])), sessionID: SessionID, @@ -153,8 +153,8 @@ export const Info = Schema.Struct({ title: Schema.String, snapshot: Schema.optional(Schema.String), time: Schema.Struct({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, }), }), [Schema.Record(Schema.String, Schema.Unknown)], @@ -169,15 +169,15 @@ export const Info = Schema.Struct({ cwd: Schema.String, root: Schema.String, }), - cost: Schema.Number, + cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c376c8d1a936..1be5dfffd42d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -38,7 +38,7 @@ import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" const log = Log.create({ service: "session" }) @@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) { } const Summary = Schema.Struct({ - additions: Schema.Number, - deletions: Schema.Number, - files: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, + files: NonNegativeInt, diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), }) @@ -143,10 +143,10 @@ const Share = Schema.Struct({ }) const Time = Schema.Struct({ - created: Schema.Number, - updated: Schema.Number, - compacting: optionalOmitUndefined(Schema.Number), - archived: optionalOmitUndefined(Schema.Number), + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(NonNegativeInt), }) const Revert = Schema.Struct({ @@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema ) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, - time: Schema.optional(Schema.Number), + time: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, @@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const MessagesInput = Schema.Struct({ sessionID: SessionID, - limit: Schema.optional(Schema.Number), + limit: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) const CreatedEventSchema = Schema.Struct({ @@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({ }) const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(Schema.Number)), - updated: Schema.optional(Schema.NullOr(Schema.Number)), - compacting: Schema.optional(Schema.NullOr(Schema.Number)), - archived: Schema.optional(Schema.NullOr(Schema.Number)), + created: Schema.optional(Schema.NullOr(NonNegativeInt)), + updated: Schema.optional(Schema.NullOr(NonNegativeInt)), + compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(NonNegativeInt)), }) const UpdatedInfo = Schema.Struct({ diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index fdd561b4ae5e..a0e57afc22bd 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,7 +3,7 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" @@ -13,9 +13,9 @@ export const Info = Schema.Union([ }), Schema.Struct({ type: Schema.Literal("retry"), - attempt: Schema.Number, + attempt: NonNegativeInt, message: Schema.String, - next: Schema.Number, + next: NonNegativeInt, }), Schema.Struct({ type: Schema.Literal("busy"), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index cd28377aa701..ea30f5afc7ca 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,7 +10,7 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import { zod } from "@/util/effect-zod" export const Patch = Schema.Struct({ @@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, patch: Schema.String, - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index af18d88b34ae..5b2df1e899a6 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,6 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" +import { NonNegativeInt } from "@/util/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) @@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({ }) const DiffFile = Schema.Struct({ - additions: Schema.Number, - deletions: Schema.Number, + additions: NonNegativeInt, + deletions: NonNegativeInt, }) const SummaryFile = Schema.Struct({ diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index ad899531bace..67bc9b9e7cc3 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -294,4 +294,20 @@ export function payloads() { .toArray() } +export function effectPayloads() { + return registry + .entries() + .map(([type, def]) => + EffectSchema.Struct({ + type: EffectSchema.Literal("sync"), + name: EffectSchema.Literal(type), + id: EffectSchema.String, + seq: EffectSchema.Finite, + aggregateID: EffectSchema.Literal(def.aggregate), + data: def.schema, + }).annotate({ identifier: `SyncEvent.${type}` }), + ) + .toArray() +} + export * as SyncEvent from "." diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 82f6e5aaeb82..c32c3963baf4 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { PositiveInt } from "@/util/schema" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs export const Parameters = Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in milliseconds" }), + 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.`, }), diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index e10d21175e73..2753732dd0ae 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -9,7 +9,7 @@ export const Parameters = Schema.Struct({ description: "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", }), - tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)) .check(Schema.isLessThanOrEqualTo(50000)) .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) .annotate({ diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 828beeefef86..3a555c2ce826 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -23,12 +23,12 @@ const operations = [ export const Parameters = Schema.Struct({ operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }), filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }), - line: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The line number (1-based, as shown in editors)" }), - character: Schema.Number.check(Schema.isInt()) - .check(Schema.isGreaterThanOrEqualTo(1)) - .annotate({ description: "The character offset (1-based, as shown in editors)" }), + line: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The line number (1-based, as shown in editors)", + }), + character: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).annotate({ + description: "The character offset (1-based, as shown in editors)", + }), query: Schema.optional(Schema.String).annotate({ description: "Search query for workspaceSymbol. Empty string requests all symbols.", }), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 0f528b8f65fe..fb386f579050 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,4 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" +import { NonNegativeInt } from "@/util/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" @@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096 // unchanged; purely CLI-facing uses must now send numbers rather than strings. export const Parameters = Schema.Struct({ filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }), - offset: Schema.optional(Schema.Number).annotate({ + offset: Schema.optional(NonNegativeInt).annotate({ description: "The line number to start reading from (1-indexed)", }), - limit: Schema.optional(Schema.Number).annotate({ + limit: Schema.optional(NonNegativeInt).annotate({ description: "The maximum number of lines to read (defaults to 2000)", }), }) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index e144f2f90640..d9b92a23cc89 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -26,10 +26,17 @@ export function namedSchemaError class NamedSchemaError extends Error { static readonly Schema = wire + static readonly EffectSchema = effectSchema static readonly tag = tag public static isInstance(input: unknown): input is NamedSchemaError { return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 2a6c02349fbb..380225316c9a 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -11,6 +11,8 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + + /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index b261d8b5b25d..66576a688e7e 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { NonNegativeInt } from "@/util/schema" import { SessionEvent } from "./session-event" export const ID = SessionEvent.ID @@ -105,7 +106,7 @@ export class AssistantReasoning extends Schema.Class("Sessio }) {} export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: Schema.Number, + attempt: NonNegativeInt, error: SessionEvent.RetryError, time: Schema.Struct({ created: Schema.DateTimeUtc, @@ -132,14 +133,14 @@ export class Assistant extends Schema.Class("Session.Entry.Assistant" type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Number.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }).pipe(Schema.optional), error: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f922becf3af0..aaf71c8dccdb 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@/util/schema" import * as DateTime from "effect/DateTime" import { Schema } from "effect" @@ -25,8 +25,8 @@ export namespace SessionEvent { } export class Source extends Schema.Class("Session.Event.Source")({ - start: Schema.Number, - end: Schema.Number, + start: NonNegativeInt, + end: NonNegativeInt, text: Schema.String, }) {} @@ -55,7 +55,7 @@ export namespace SessionEvent { export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ message: Schema.String, - statusCode: Schema.Number.pipe(Schema.optional), + statusCode: NonNegativeInt.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -123,14 +123,14 @@ export namespace SessionEvent { ...Base, type: Schema.Literal("step.ended"), reason: Schema.String, - cost: Schema.Number, + cost: Schema.Finite, tokens: Schema.Struct({ - input: Schema.Number, - output: Schema.Number, - reasoning: Schema.Number, + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, cache: Schema.Struct({ - read: Schema.Number, - write: Schema.Number, + read: NonNegativeInt, + write: NonNegativeInt, }), }), }) { @@ -395,7 +395,7 @@ export namespace SessionEvent { export class Retried extends Schema.Class("Session.Event.Retried")({ ...Base, type: Schema.Literal("retried"), - attempt: Schema.Number, + attempt: NonNegativeInt, error: RetryError, }) { static create(input: BaseInput & { attempt: number; error: RetryError }) { diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 7a7105dfaa86..a0324cce393a 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { ControlPaths } from "../../src/server/routes/instance/httpapi/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -57,16 +57,32 @@ function openApiParameters(spec: { paths: Record>> }) { +function openApiRequestBodies(spec: OpenApiSpec) { return Object.fromEntries( Object.entries(spec.paths).flatMap(([path, item]) => methods .filter((method) => item[method]) - .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]), + .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]), ), ) } +type OpenApiSpec = { + components?: { + schemas?: Record + } + paths: Record>> +} + +type OpenApiSchema = { + $ref?: string + allOf?: unknown[] + anyOf?: unknown[] + oneOf?: unknown[] + properties?: Record + type?: string | string[] +} + type Operation = { parameters?: unknown[] responses?: unknown @@ -74,7 +90,7 @@ type Operation = { } type RequestBody = { - content?: Record + content?: Record required?: boolean } @@ -97,17 +113,27 @@ function parameterSchema(input: { return param.schema } -function requestBodyKey(body: unknown) { +function requestBodyKey(spec: OpenApiSpec, body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" const requestBody = body as RequestBody return JSON.stringify({ required: requestBody.required === true, content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"]) + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)]) .sort(), }) } +function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { + if (!schema) return "" + const resolved = (schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema) as + | OpenApiSchema + | undefined + if (resolved?.properties) return "object" + if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" + return resolved?.type ?? schema.type ?? "inline" +} + function responseContentTypes(input: { spec: { paths: Record>> } path: string @@ -146,6 +172,14 @@ afterEach(async () => { }) describe("HttpApi server", () => { + test("keeps Effect HttpApi behind the feature flag", () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" }) + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" }) + }) + test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { const honoRoutes = openApiRouteKeys(await Server.openapi()) const effectRoutes = openApiRouteKeys(effectOpenApi()) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 3978631b878a..a4b0b6619901 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -4,7 +4,7 @@ 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/experimental" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index f9e94eeaa507..b7425007e152 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context } from "effect" import path from "path" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { FilePaths } from "../../src/server/routes/instance/httpapi/file" +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" diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 4ab1da11e64a..8e48284deaa6 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,7 +4,7 @@ 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/instance" +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" diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 555c717cf009..b88a032f5d7c 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } type TestApp = ReturnType diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index bb6635b52f00..e348866528dd 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" 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/mcp" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -19,7 +19,7 @@ const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } type TestApp = ReturnType diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 8d03311d912e..5e8ff01a0e1b 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -19,7 +19,7 @@ const oauthInstructions = "Finish OAuth" function app(experimental: boolean) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return Server.Default().app + return experimental ? Server.Default().app : Server.Legacy().app } function requestAuthorize(input: { diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 87e2a9412032..37d2a4f64d92 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty" +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 } from "../fixture/fixture" diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index d02285946935..c0984170be5c 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,40 +1,209 @@ import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { MessageV2 } from "../../src/session/message-v2" +import { ModelID, ProviderID } from "../../src/provider/schema" +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" -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} +type Backend = "legacy" | "httpapi" +type Sdk = ReturnType +type SdkResult = { response: Response; data?: unknown; error?: unknown } + +function app(backend: Backend, input?: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + return backend === "httpapi" ? Server.Default().app : Server.Legacy().app +} -function client(directory?: string) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const handler = ExperimentalHttpApiServer.webHandler().handler +function client( + backend: Backend, + directory?: string, + input?: { password?: string; username?: string; headers?: Record }, +) { + const serverApp = app(backend, input) const fetch = Object.assign( - (request: RequestInfo | URL, init?: RequestInit) => - handler(new Request(request, init), ExperimentalHttpApiServer.context), + async (request: RequestInfo | URL, init?: RequestInit) => + await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), { preconnect: globalThis.fetch.preconnect }, ) satisfies typeof globalThis.fetch return createOpencodeClient({ baseUrl: "http://localhost", directory, + headers: input?.headers, fetch, }) } +function authorization(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +function providerConfig(url: string) { + return { + formatter: false, + lsp: false, + 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, + }, + }, + }, + } +} + async function expectStatus(result: Promise<{ response: Response }>, status: number) { expect((await result).response.status).toBe(status) } +async function capture(result: Promise) { + const response = await result + return { + status: response.response.status, + data: response.data, + error: response.error, + } +} + +function record(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} +} + +function array(value: unknown) { + return Array.isArray(value) ? value : [] +} + +function statuses(input: Record>>) { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status])) +} + +function firstPartText(value: unknown) { + return record(array(record(value).parts)[0]).text +} + +function sessionTitles(value: unknown) { + return array(value) + .map((item) => record(item).title) + .filter((title): title is string => typeof title === "string") + .sort() +} + +async function runSession(directory: string, effect: Effect.Effect) { + return Instance.provide({ + directory, + fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))), + }) +} + +async function seedMessage(directory: string, sessionID: string) { + const id = SessionID.make(sessionID) + return runSession( + directory, + SessionNs.Service.use((svc) => + Effect.gen(function* () { + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + sessionID: id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: id, + messageID: message.id, + type: "text", + text: "seeded message", + }) + return { message, part } + }), + ), + ) +} + +async function compareBackends(scenario: (backend: Backend) => Promise) { + const legacy = await scenario("legacy") + await Instance.disposeAll() + await resetDatabase() + const httpapi = await scenario("httpapi") + expect(httpapi).toEqual(legacy) +} + +async function withTmp(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise) { + await using tmp = await tmpdir({ + git: true, + config: { formatter: false, lsp: false }, + init: async (dir) => { + await Bun.write(path.join(dir, "hello.txt"), "hello") + await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n") + }, + }) + return fn({ sdk: client(backend, tmp.path), directory: tmp.path }) +} + +async function withFakeLlm( + backend: Backend, + fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise, +) { + return Effect.runPromise( + Effect.gen(function* () { + const llm = yield* TestLLMServer + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm })) + }).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)), + ) +} + afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + 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 resetDatabase() }) describe("HttpApi SDK", () => { test("uses the generated SDK for global and control routes", async () => { - const sdk = client() + const sdk = client("httpapi") const health = await sdk.global.health() expect(health.response.status).toBe(200) @@ -60,7 +229,7 @@ describe("HttpApi SDK", () => { config: { formatter: false, lsp: false }, init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"), }) - const sdk = client(tmp.path) + const sdk = client("httpapi", tmp.path) const file = await sdk.file.read({ path: "hello.txt" }) expect(file.response.status).toBe(200) @@ -81,4 +250,381 @@ describe("HttpApi SDK", () => { expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200), ]) }) + + test("matches generated SDK global and control behavior across backends", async () => { + await compareBackends(async (backend) => { + const sdk = client(backend) + const health = await capture(sdk.global.health()) + const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) + const invalidAuth = await capture(sdk.auth.set({ providerID: "test" })) + + return { + statuses: statuses({ health, log, invalidAuth }), + health: record(health.data).healthy, + log: log.data, + } + }) + }) + + test("matches generated SDK global event stream across backends", async () => { + await compareBackends(async (backend) => { + const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) }) + try { + const first = await events.stream.next() + return { + type: record(record(first.value).payload).type, + } + } finally { + await events.stream.return(undefined) + } + }) + }) + + test("matches generated SDK instance event stream across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) }) + try { + const first = await events.stream.next() + return { + type: record(record(first.value).payload).type, + } + } finally { + await events.stream.return(undefined) + } + }), + ) + }) + + test("matches generated SDK basic auth behavior across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ directory }) => { + const missing = await capture( + client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), + ) + const bad = await capture( + client(backend, directory, { + password: "secret", + headers: { authorization: authorization("opencode", "wrong") }, + }).file.read({ path: "hello.txt" }), + ) + const good = await capture( + client(backend, directory, { + password: "secret", + headers: { authorization: authorization("opencode", "secret") }, + }).file.read({ path: "hello.txt" }), + ) + + return { + statuses: statuses({ missing, bad, good }), + content: record(good.data).content, + } + }), + ) + }) + + test("matches generated SDK instance read routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk, directory }) => { + const project = await capture(sdk.project.current()) + const projects = await capture(sdk.project.list()) + const paths = await capture(sdk.path.get()) + const config = await capture(sdk.config.get()) + const providers = await capture(sdk.config.providers()) + const file = await capture(sdk.file.read({ path: "hello.txt" })) + const files = await capture(sdk.file.list({ path: "." })) + const fileStatus = await capture(sdk.file.status()) + const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 })) + const findText = await capture(sdk.find.text({ pattern: "sdk-parity" })) + const agents = await capture(sdk.app.agents()) + const skills = await capture(sdk.app.skills()) + const tools = await capture(sdk.tool.ids()) + const vcs = await capture(sdk.vcs.get()) + const formatter = await capture(sdk.formatter.status()) + const lsp = await capture(sdk.lsp.status()) + + return { + statuses: statuses({ + project, + projects, + paths, + config, + providers, + file, + files, + fileStatus, + findFiles, + findText, + agents, + skills, + tools, + vcs, + formatter, + lsp, + }), + project: { + worktreeSelected: record(project.data).worktree === directory, + }, + paths: { + cwdSelected: record(paths.data).cwd === directory, + }, + file: record(file.data).content, + hasProject: array(projects.data).length > 0, + foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), + foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"), + listedFile: JSON.stringify(files.data).includes("hello.txt"), + } + }), + ) + }) + + test("matches generated SDK session lifecycle routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const parent = await capture(sdk.session.create({ title: "parent" })) + const parentID = String(record(parent.data).id) + const child = await capture(sdk.session.create({ title: "child", parentID })) + const childID = String(record(child.data).id) + const get = await capture(sdk.session.get({ sessionID: parentID })) + const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" })) + const roots = await capture(sdk.session.list({ roots: true, limit: 10 })) + const all = await capture(sdk.session.list({ roots: false, limit: 10 })) + const children = await capture(sdk.session.children({ sessionID: parentID })) + const todo = await capture(sdk.session.todo({ sessionID: parentID })) + const status = await capture(sdk.session.status()) + const messages = await capture(sdk.session.messages({ sessionID: parentID })) + const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" })) + const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) + const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) + const deleted = await capture(sdk.session.delete({ sessionID: childID })) + const getDeleted = await capture(sdk.session.get({ sessionID: childID })) + + return { + statuses: statuses({ + parent, + child, + get, + update, + roots, + all, + children, + todo, + status, + messages, + missingGet, + missingMessages, + invalidCursor, + deleted, + getDeleted, + }), + getTitle: record(get.data).title, + updatedTitle: record(update.data).title, + rootTitles: sessionTitles(roots.data), + allTitles: sessionTitles(all.data), + childCount: array(children.data).length, + todoCount: array(todo.data).length, + messageCount: array(messages.data).length, + } + }), + ) + }) + + test("matches generated SDK session message and part routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk, directory }) => { + const session = await capture(sdk.session.create({ title: "messages" })) + const sessionID = String(record(session.data).id) + const seeded = await seedMessage(directory, sessionID) + const list = await capture(sdk.session.messages({ sessionID })) + const page = await capture(sdk.session.messages({ sessionID, limit: 1 })) + const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partUpdate = await capture( + sdk.part.update({ + sessionID, + messageID: seeded.message.id, + partID: seeded.part.id, + part: { + ...seeded.part, + text: "updated message", + } as NonNullable[0]["part"]>, + }), + ) + const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partDelete = await capture( + sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), + ) + const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) + const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + + return { + statuses: statuses({ + session, + list, + page, + message, + partUpdate, + updated, + partDelete, + withoutPart, + deleteMessage, + missingMessage, + }), + listCount: array(list.data).length, + pageCount: array(page.data).length, + initialText: firstPartText(message.data), + updatedText: firstPartText(updated.data), + partCountAfterDelete: array(record(withoutPart.data).parts).length, + } + }), + ) + }) + + test("matches generated SDK prompt no-reply routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const session = await capture(sdk.session.create({ title: "prompt" })) + const sessionID = String(record(session.data).id) + const prompt = await capture( + sdk.session.prompt({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }), + ) + const asyncPrompt = await capture( + sdk.session.promptAsync({ + sessionID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "async hello" }], + }), + ) + const messages = await capture(sdk.session.messages({ sessionID })) + + return { + statuses: statuses({ session, prompt, asyncPrompt, messages }), + promptRole: record(record(prompt.data).info).role, + messageCount: array(messages.data).length, + messageTexts: array(messages.data) + .flatMap((item) => array(record(item).parts)) + .map((part) => record(part).text) + .filter((text): text is string => typeof text === "string") + .sort(), + } + }), + ) + }) + + test("matches generated SDK prompt streaming through fake LLM across backends", async () => { + await compareBackends((backend) => + withFakeLlm(backend, async ({ sdk, llm }) => { + await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } })) + const session = await capture( + sdk.session.create({ + title: "llm prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = await capture( + sdk.session.prompt({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }), + ) + const messages = await capture(sdk.session.messages({ sessionID })) + const inputs = await Effect.runPromise(llm.inputs) + + return { + statuses: statuses({ session, prompt, messages }), + calls: inputs.length, + requestedModel: inputs[0]?.model, + responseText: JSON.stringify(prompt.data).includes("fake world"), + persistedText: JSON.stringify(messages.data).includes("fake world"), + userText: JSON.stringify(messages.data).includes("hello llm"), + } + }), + ) + }) + + test("matches generated SDK TUI validation and command routes across backends", async () => { + await compareBackends((backend) => + withTmp(backend, async ({ sdk }) => { + const session = await capture(sdk.session.create({ title: "tui" })) + const sessionID = String(record(session.data).id) + const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" })) + const openHelp = await capture(sdk.tui.openHelp()) + const openSessions = await capture(sdk.tui.openSessions()) + const openThemes = await capture(sdk.tui.openThemes()) + const openModels = await capture(sdk.tui.openModels()) + const submitPrompt = await capture(sdk.tui.submitPrompt()) + const clearPrompt = await capture(sdk.tui.clearPrompt()) + const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" })) + const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) + const selectSession = await capture(sdk.tui.selectSession({ sessionID })) + const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" })) + const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" })) + + return { + statuses: statuses({ + session, + appendPrompt, + openHelp, + openSessions, + openThemes, + openModels, + submitPrompt, + clearPrompt, + executeCommand, + showToast, + selectSession, + missingSession, + invalidSession, + }), + data: { + appendPrompt: appendPrompt.data, + openHelp: openHelp.data, + openSessions: openSessions.data, + openThemes: openThemes.data, + openModels: openModels.data, + submitPrompt: submitPrompt.data, + clearPrompt: clearPrompt.data, + executeCommand: executeCommand.data, + showToast: showToast.data, + selectSession: selectSession.data, + }, + } + }), + ) + }) + + test("matches generated SDK project git initialization across backends", async () => { + await compareBackends(async (backend) => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(backend, tmp.path) + const before = await capture(sdk.project.current()) + const init = await capture(sdk.project.initGit()) + const after = await capture(sdk.project.current()) + + return { + statuses: statuses({ before, init, after }), + before: { + vcs: record(before.data).vcs ?? null, + worktree: record(before.data).worktree, + }, + init: { + vcs: record(init.data).vcs, + worktreeSelected: record(init.data).worktree === tmp.path, + }, + after: { + vcs: record(after.data).vcs, + worktreeSelected: record(after.data).worktree === tmp.path, + }, + } + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 3e3fb3573104..593f9765c7f0 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 275819105798..5fa6784a1389 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync" +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" @@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function app(httpapi = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi - return Server.Default().app + return httpapi ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 81a2105095f9..9f7c8e9e8994 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -3,7 +3,7 @@ import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui" +import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" import { callTui } from "../../src/server/routes/instance/tui" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index cb549c649750..f430105714ca 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" @@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdaptor } from "../../src/control-plane/adaptors" import type { WorkspaceAdaptor } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" 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 { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" void Log.init({ print: false }) @@ -54,7 +55,41 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } +function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { + return { + name: "Remote Test", + description: "Create a remote test workspace", + configure(info) { + return { + ...info, + name: "remote-test", + directory, + } + }, + async create() { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target() { + return { + type: "remote" as const, + url, + } + }, + } +} + +function eventStreamResponse() { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }) +} + afterEach(async () => { + mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await Instance.disposeAll() @@ -125,4 +160,81 @@ describe("workspace HttpApi", () => { expect(listed.status).toBe(200) expect(await listed.json()).toEqual([]) }) + + test("routes local workspace requests through the workspace target directory", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const workspaceDir = path.join(tmp.path, ".workspace-local") + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir)) + return Workspace.create({ + type: "local-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + try { + const response = await request(url.toString(), tmp.path) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ directory: workspaceDir }) + } finally { + await Workspace.remove(workspace.id) + } + }) + + test("proxies remote workspace HTTP requests", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const proxied: string[] = [] + const rawFetch = globalThis.fetch + spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + proxied.push(url.toString()) + return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") }) + }, + { + preconnect: rawFetch.preconnect?.bind(rawFetch), + }, + ) as typeof globalThis.fetch, + ) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base")) + return Workspace.create({ + type: "remote-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + try { + const response = await request(url.toString(), tmp.path) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null }) + expect(proxied).toEqual(["https://remote.test/base/path"]) + } finally { + await Workspace.remove(workspace.id) + } + }) }) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b20665b34dce..02de54406a4c 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -43,7 +43,9 @@ Output: Creates directory 'foo'" }, "timeout": { "description": "Optional timeout in milliseconds", - "type": "number", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer", }, "workdir": { "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.", @@ -71,7 +73,7 @@ exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = ` "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", "maximum": 50000, "minimum": 1000, - "type": "number", + "type": "integer", }, }, "required": [ @@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = ` } `; - exports[`tool parameters JSON Schema (wire shape) plan 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = ` }, "limit": { "description": "The maximum number of lines to read (defaults to 2000)", - "type": "number", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", }, "offset": { "description": "The line number to start reading from (1-indexed)", - "type": "number", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", }, }, "required": [ From df147b65fd7f0739bc4e39f0698c788387cb4974 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Apr 2026 13:36:05 +0000 Subject: [PATCH 0002/1114] chore: generate --- .../routes/instance/httpapi/groups/pty.ts | 8 +- .../routes/instance/httpapi/groups/tui.ts | 55 ++- .../instance/httpapi/groups/workspace.ts | 4 +- .../instance/httpapi/handlers/config.ts | 40 +- .../instance/httpapi/handlers/control.ts | 40 +- .../instance/httpapi/handlers/experimental.ts | 246 ++++++------ .../routes/instance/httpapi/handlers/file.ts | 88 ++--- .../instance/httpapi/handlers/global.ts | 154 ++++---- .../instance/httpapi/handlers/instance.ts | 108 +++--- .../routes/instance/httpapi/handlers/mcp.ts | 102 ++--- .../instance/httpapi/handlers/permission.ts | 34 +- .../instance/httpapi/handlers/project.ts | 54 +-- .../instance/httpapi/handlers/provider.ts | 140 +++---- .../routes/instance/httpapi/handlers/pty.ts | 4 +- .../instance/httpapi/handlers/question.ts | 40 +- .../instance/httpapi/handlers/session.ts | 83 ++-- .../instance/httpapi/instance-context.ts | 51 ++- .../routes/instance/httpapi/lifecycle.ts | 5 +- .../server/routes/instance/httpapi/public.ts | 34 +- packages/opencode/src/server/server.ts | 9 +- packages/opencode/src/session/message-v2.ts | 4 +- packages/opencode/src/util/schema.ts | 2 - .../test/server/httpapi-bridge.test.ts | 6 +- .../test/server/httpapi-workspace.test.ts | 6 +- packages/sdk/openapi.json | 362 +++++++++++++----- 25 files changed, 971 insertions(+), 708 deletions(-) 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 e3914579c12f..eb71526fb3e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -29,14 +29,18 @@ export const PtyApi = HttpApi.make("pty") .add( HttpApiGroup.make("pty") .add( - HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge( + HttpApiEndpoint.get("shells", PtyPaths.shells, { + success: described(Schema.Array(ShellItem), "List of shells"), + }).annotateMerge( OpenApi.annotations({ identifier: "pty.shells", summary: "List available shells", description: "Get a list of available shells on the system.", }), ), - HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge( + HttpApiEndpoint.get("list", PtyPaths.list, { + success: described(Schema.Array(Pty.Info), "List of sessions"), + }).annotateMerge( OpenApi.annotations({ identifier: "pty.list", summary: "List PTY sessions", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index a5d31bfa6212..49ba05c2d517 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -11,11 +11,28 @@ export const TuiRequestPayload = Schema.Struct({ path: Schema.String, body: Schema.Unknown, }) -const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }) -const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }) -const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }) -const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }) -export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect]) +const EventTuiPromptAppend = Schema.Struct({ + type: Schema.Literal(TuiEvent.PromptAppend.type), + properties: TuiEvent.PromptAppend.properties, +}).annotate({ identifier: "EventTuiPromptAppend" }) +const EventTuiCommandExecute = Schema.Struct({ + type: Schema.Literal(TuiEvent.CommandExecute.type), + properties: TuiEvent.CommandExecute.properties, +}).annotate({ identifier: "EventTuiCommandExecute" }) +const EventTuiToastShow = Schema.Struct({ + type: Schema.Literal(TuiEvent.ToastShow.type), + properties: TuiEvent.ToastShow.properties, +}).annotate({ identifier: "EventTuiToastShow" }) +const EventTuiSessionSelect = Schema.Struct({ + type: Schema.Literal(TuiEvent.SessionSelect.type), + properties: TuiEvent.SessionSelect.properties, +}).annotate({ identifier: "EventTuiSessionSelect" }) +export const TuiPublishPayload = Schema.Union([ + EventTuiPromptAppend, + EventTuiCommandExecute, + EventTuiToastShow, + EventTuiSessionSelect, +]) export const TuiPaths = { appendPrompt: `${root}/append-prompt`, @@ -48,42 +65,54 @@ export const TuiApi = HttpApi.make("tui") description: "Append prompt to the TUI.", }), ), - HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge( + HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + success: described(Schema.Boolean, "Help dialog opened successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.openHelp", summary: "Open help dialog", description: "Open the help dialog in the TUI to display user assistance information.", }), ), - HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge( + HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + success: described(Schema.Boolean, "Session dialog opened successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.openSessions", summary: "Open sessions dialog", description: "Open the session dialog.", }), ), - HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge( + HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + success: described(Schema.Boolean, "Theme dialog opened successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.openThemes", summary: "Open themes dialog", description: "Open the theme dialog.", }), ), - HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge( + HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + success: described(Schema.Boolean, "Model dialog opened successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.openModels", summary: "Open models dialog", description: "Open the model dialog.", }), ), - HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge( + HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + success: described(Schema.Boolean, "Prompt submitted successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.submitPrompt", summary: "Submit TUI prompt", description: "Submit the prompt.", }), ), - HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge( + HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + success: described(Schema.Boolean, "Prompt cleared successfully"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.clearPrompt", summary: "Clear TUI prompt", @@ -133,7 +162,9 @@ export const TuiApi = HttpApi.make("tui") description: "Navigate the TUI to display the specified session.", }), ), - HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge( + HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + success: described(TuiRequestPayload, "Next TUI request"), + }).annotateMerge( OpenApi.annotations({ identifier: "tui.control.next", summary: "Get next TUI request", 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 0305c65365d7..ab5f08bb1729 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -9,9 +9,7 @@ import { described } from "./metadata" const root = "/experimental/workspace" export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) -export const SessionRestorePayload = Schema.Struct( - Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]), -) +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/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 2fc225d1714c..58aa81098c75 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -7,28 +7,28 @@ import { InstanceHttpApi } from "../api" import { markInstanceForDisposal } from "../lifecycle" export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) => - Effect.gen(function* () { - const providerSvc = yield* Provider.Service - const configSvc = yield* Config.Service + Effect.gen(function* () { + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service - const get = Effect.fn("ConfigHttpApi.get")(function* () { - return yield* configSvc.get() - }) + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) - const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) - yield* markInstanceForDisposal(yield* InstanceState.context) - return ctx.payload - }) + const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { + yield* configSvc.update(ctx.payload, { dispose: false }) + yield* markInstanceForDisposal(yield* InstanceState.context) + return ctx.payload + }) - const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* providerSvc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }) + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const providers = yield* providerSvc.list() + return { + providers: Object.values(providers), + default: Provider.defaultModelIDs(providers), + } + }) - return handlers.handle("get", get).handle("update", update).handle("providers", providers) - }), + return handlers.handle("get", get).handle("update", update).handle("providers", providers) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index abddd8c40235..e1ede2274b69 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -7,28 +7,28 @@ import { RootHttpApi } from "../api" import { LogInput } from "../groups/control" export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => - Effect.gen(function* () { - const auth = yield* Auth.Service + Effect.gen(function* () { + const auth = yield* Auth.Service - const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { - params: { providerID: ProviderID } - payload: Auth.Info - }) { - yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) - return true - }) + const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { + params: { providerID: ProviderID } + payload: Auth.Info + }) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + }) - const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { - yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) - return true - }) + const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + }) - const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { - const logger = Log.create({ service: ctx.payload.service }) - logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) - return true - }) + const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { + const logger = Log.create({ service: ctx.payload.service }) + logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) + return true + }) - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) - }), + return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 42eab762e8da..cc958da30379 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -15,141 +15,141 @@ import { InstanceHttpApi } from "../api" import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental" export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) => - Effect.gen(function* () { - const account = yield* Account.Service - const agents = yield* Agent.Service - const config = yield* Config.Service - const mcp = yield* MCP.Service - const project = yield* Project.Service - const registry = yield* ToolRegistry.Service - const worktreeSvc = yield* Worktree.Service + Effect.gen(function* () { + const account = yield* Account.Service + const agents = yield* Agent.Service + const config = yield* Config.Service + const mcp = yield* MCP.Service + const project = yield* Project.Service + const registry = yield* ToolRegistry.Service + const worktreeSvc = yield* Worktree.Service - const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { - const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - return { - consoleManagedProviders: state.consoleManagedProviders, - ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }) + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { + const [state, groups] = yield* Effect.all( + [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + return { + consoleManagedProviders: state.consoleManagedProviders, + ...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}), + switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), + } + }) - const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { - const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], - { - concurrency: "unbounded", - }, - ) - const info = Option.getOrUndefined(active) - return { - orgs: groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ), - } - }) + const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { + const [groups, active] = yield* Effect.all( + [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + { + concurrency: "unbounded", + }, + ) + const info = Option.getOrUndefined(active) + return { + orgs: groups.flatMap((group) => + group.orgs.map((org) => ({ + accountID: group.account.id, + accountEmail: group.account.email, + accountUrl: group.account.url, + orgID: org.id, + orgName: org.name, + active: !!info && info.id === group.account.id && info.active_org_id === org.id, + })), + ), + } + }) - const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { - payload: typeof ConsoleSwitchPayload.Type - }) { - yield* account - .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) + const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: { + payload: typeof ConsoleSwitchPayload.Type + }) { + yield* account + .use(ctx.payload.accountID, Option.some(ctx.payload.orgID)) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) - const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { - const list = yield* registry.tools({ - providerID: ctx.query.provider, - modelID: ctx.query.model, - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - return list.map((item) => ({ - id: item.id, - description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), - })) + const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) { + const list = yield* registry.tools({ + providerID: ctx.query.provider, + modelID: ctx.query.model, + agent: yield* agents.get(yield* agents.defaultAgent()), }) + return list.map((item) => ({ + id: item.id, + description: item.description, + parameters: EffectZod.toJsonSchema(item.parameters), + })) + }) - const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { - return yield* registry.ids() - }) + const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () { + return yield* registry.ids() + }) - const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { - const ctx = yield* InstanceState.context - return yield* project.sandboxes(ctx.project.id) - }) + const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { + const ctx = yield* InstanceState.context + return yield* project.sandboxes(ctx.project.id) + }) - const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { - payload: Worktree.CreateInput | undefined - }) { - return yield* worktreeSvc.create(ctx.payload) - }) + const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { + payload: Worktree.CreateInput | undefined + }) { + return yield* worktreeSvc.create(ctx.payload) + }) - const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { - payload: Worktree.RemoveInput - }) { - const ctx = yield* InstanceState.context - yield* worktreeSvc.remove(input.payload) - yield* project.removeSandbox(ctx.project.id, input.payload.directory) - return true - }) + const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: { + payload: Worktree.RemoveInput + }) { + const ctx = yield* InstanceState.context + yield* worktreeSvc.remove(input.payload) + yield* project.removeSandbox(ctx.project.id, input.payload.directory) + return true + }) - const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { - payload: Worktree.ResetInput - }) { - yield* worktreeSvc.reset(ctx.payload) - return true - }) + const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: { + payload: Worktree.ResetInput + }) { + yield* worktreeSvc.reset(ctx.payload) + return true + }) - const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { - const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions - return HttpServerResponse.jsonUnsafe(list, { - headers: - sessions.length > limit && list.length > 0 - ? { "x-next-cursor": String(list[list.length - 1].time.updated) } - : undefined, - }) + const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { + const limit = ctx.query.limit ?? 100 + const sessions = Array.from( + Session.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }), + ) + const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + return HttpServerResponse.jsonUnsafe(list, { + headers: + sessions.length > limit && list.length > 0 + ? { "x-next-cursor": String(list[list.length - 1].time.updated) } + : undefined, }) + }) - const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { - return yield* mcp.resources() - }) + const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { + return yield* mcp.resources() + }) - return handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - .handle("session", session) - .handle("resource", resource) - }), + return handlers + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + .handle("session", session) + .handle("resource", resource) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 72133e8dea30..98ee5968e0cd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -6,49 +6,49 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => - Effect.gen(function* () { - const svc = yield* File.Service - const ripgrep = yield* Ripgrep.Service - - const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { - return (yield* ripgrep - .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) - .pipe(Effect.orDie)).items + Effect.gen(function* () { + const svc = yield* File.Service + const ripgrep = yield* Ripgrep.Service + + const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { + return (yield* ripgrep + .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 }) + .pipe(Effect.orDie)).items + }) + + const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } + }) { + return yield* svc.search({ + query: ctx.query.query, + limit: ctx.query.limit ?? 10, + dirs: ctx.query.dirs !== "false", + type: ctx.query.type, }) - - const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { - query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } - }) { - return yield* svc.search({ - query: ctx.query.query, - limit: ctx.query.limit ?? 10, - dirs: ctx.query.dirs !== "false", - type: ctx.query.type, - }) - }) - - const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { - return [] - }) - - const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { - return yield* svc.list(ctx.query.path) - }) - - const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { - return yield* svc.read(ctx.query.path) - }) - - const status = Effect.fn("FileHttpApi.status")(function* () { - return yield* svc.status() - }) - - return handlers - .handle("findText", findText) - .handle("findFile", findFile) - .handle("findSymbol", findSymbol) - .handle("list", list) - .handle("content", content) - .handle("status", status) - }), + }) + + const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () { + return [] + }) + + const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) { + return yield* svc.list(ctx.query.path) + }) + + const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + return yield* svc.read(ctx.query.path) + }) + + const status = Effect.fn("FileHttpApi.status")(function* () { + return yield* svc.status() + }) + + return handlers + .handle("findText", findText) + .handle("findFile", findFile) + .handle("findSymbol", findSymbol) + .handle("list", list) + .handle("content", content) + .handle("status", status) + }), ) 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 5972395512af..cd1bebec47a1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -65,92 +65,92 @@ function eventResponse() { } export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) => - Effect.gen(function* () { - const config = yield* Config.Service - const installation = yield* Installation.Service + Effect.gen(function* () { + const config = yield* Config.Service + const installation = yield* Installation.Service - const health = Effect.fn("GlobalHttpApi.health")(function* () { - return { healthy: true as const, version: InstallationVersion } - }) + const health = Effect.fn("GlobalHttpApi.health")(function* () { + return { healthy: true as const, version: InstallationVersion } + }) - const event = Effect.fn("GlobalHttpApi.event")(function* () { - return eventResponse() - }) + const event = Effect.fn("GlobalHttpApi.event")(function* () { + return eventResponse() + }) - const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { - return yield* config.getGlobal() - }) + const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { + return yield* config.getGlobal() + }) - const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) - }) + const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { + return yield* config.updateGlobal(ctx.payload) + }) - const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) - return true + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { + yield* Effect.promise(() => Instance.disposeAll()) + GlobalBus.emit("event", { + directory: "global", + payload: { type: "global.disposed", properties: {} }, }) + return true + }) - const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { - const method = yield* installation.method() - if (method === "unknown") { - return { - status: 400, - body: { success: false as const, error: "Unknown installation method" }, - } + const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) { + const method = yield* installation.method() + if (method === "unknown") { + return { + status: 400, + body: { success: false as const, error: "Unknown installation method" }, } - const target = ctx.payload.target || (yield* installation.latest(method)) - const result = yield* installation.upgrade(method, target).pipe( - Effect.as({ status: 200, body: { success: true as const, version: target } }), - Effect.catch((err) => - Effect.succeed({ - status: 500, - body: { - success: false as const, - error: err instanceof Error ? err.message : String(err), - }, - }), - ), - ) - if (!result.body.success) return result - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return result + } + const target = ctx.payload.target || (yield* installation.latest(method)) + const result = yield* installation.upgrade(method, target).pipe( + Effect.as({ status: 200, body: { success: true as const, version: target } }), + Effect.catch((err) => + Effect.succeed({ + status: 500, + body: { + success: false as const, + error: err instanceof Error ? err.message : String(err), + }, + }), + ), + ) + if (!result.body.success) return result + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Installation.Event.Updated.type, + properties: { version: target }, + }, }) + return result + }) - const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const json = parseBody(body) - if (json === undefined) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( - Effect.map((payload) => ({ valid: true as const, payload })), - Effect.catch(() => Effect.succeed({ valid: false as const })), - ) - if (!payload.valid) { - return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) - } - const result = yield* upgrade({ payload: payload.payload }) - return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) - }) + const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: { + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const json = parseBody(body) + if (json === undefined) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe( + Effect.map((payload) => ({ valid: true as const, payload })), + Effect.catch(() => Effect.succeed({ valid: false as const })), + ) + if (!payload.valid) { + return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 }) + } + const result = yield* upgrade({ payload: payload.payload }) + return HttpServerResponse.jsonUnsafe(result.body, { status: result.status }) + }) - return handlers - .handle("health", health) - .handleRaw("event", event) - .handle("configGet", configGet) - .handle("configUpdate", configUpdate) - .handle("dispose", dispose) - .handleRaw("upgrade", upgradeRaw) - }), + return handlers + .handle("health", health) + .handleRaw("event", event) + .handle("configGet", configGet) + .handle("configUpdate", configUpdate) + .handle("dispose", dispose) + .handleRaw("upgrade", upgradeRaw) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index b6f38606520a..c2a4503b481f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -12,68 +12,68 @@ import { InstanceHttpApi } from "../api" import { markInstanceForDisposal } from "../lifecycle" export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => - Effect.gen(function* () { - const agent = yield* Agent.Service - const command = yield* Command.Service - const format = yield* Format.Service - const lsp = yield* LSP.Service - const skill = yield* Skill.Service - const vcs = yield* Vcs.Service + Effect.gen(function* () { + const agent = yield* Agent.Service + const command = yield* Command.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const skill = yield* Skill.Service + const vcs = yield* Vcs.Service - const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { - yield* markInstanceForDisposal(yield* InstanceState.context) - return true - }) + const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () { + yield* markInstanceForDisposal(yield* InstanceState.context) + return true + }) - const getPath = Effect.fn("InstanceHttpApi.path")(function* () { - const ctx = yield* InstanceState.context - return { - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: ctx.worktree, - directory: ctx.directory, - } - }) + const getPath = Effect.fn("InstanceHttpApi.path")(function* () { + const ctx = yield* InstanceState.context + return { + home: Global.Path.home, + state: Global.Path.state, + config: Global.Path.config, + worktree: ctx.worktree, + directory: ctx.directory, + } + }) - const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - return { branch, default_branch } - }) + const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () { + const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + return { branch, default_branch } + }) - const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { - return yield* vcs.diff(ctx.query.mode) - }) + const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) { + return yield* vcs.diff(ctx.query.mode) + }) - const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { - return yield* command.list() - }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { + return yield* command.list() + }) - const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { - return yield* agent.list() - }) + const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () { + return yield* agent.list() + }) - const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { - return yield* skill.all() - }) + const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () { + return yield* skill.all() + }) - const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { - return yield* lsp.status() - }) + const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () { + return yield* lsp.status() + }) - const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { - return yield* format.status() - }) + const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () { + return yield* format.status() + }) - return handlers - .handle("dispose", dispose) - .handle("path", getPath) - .handle("vcs", getVcs) - .handle("vcsDiff", getVcsDiff) - .handle("command", getCommand) - .handle("agent", getAgent) - .handle("skill", getSkill) - .handle("lsp", getLsp) - .handle("formatter", getFormatter) - }), + return handlers + .handle("dispose", dispose) + .handle("path", getPath) + .handle("vcs", getVcs) + .handle("vcsDiff", getVcsDiff) + .handle("command", getCommand) + .handle("agent", getAgent) + .handle("skill", getSkill) + .handle("lsp", getLsp) + .handle("formatter", getFormatter) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index b4d27d91de6c..a02f2425ce33 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -5,64 +5,64 @@ import { InstanceHttpApi } from "../api" import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => - Effect.gen(function* () { - const mcp = yield* MCP.Service + Effect.gen(function* () { + const mcp = yield* MCP.Service - const status = Effect.fn("McpHttpApi.status")(function* () { - return yield* mcp.status() - }) + const status = Effect.fn("McpHttpApi.status")(function* () { + return yield* mcp.status() + }) - const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { - const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status - return yield* Schema.decodeUnknownEffect(StatusMap)( - "status" in result ? { [ctx.payload.name]: result } : result, - ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) - }) + const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) { + const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status + return yield* Schema.decodeUnknownEffect(StatusMap)( + "status" in result ? { [ctx.payload.name]: result } : result, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + }) - const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.startAuth(ctx.params.name) - }) + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.startAuth(ctx.params.name) + }) - const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { - params: { name: string } - payload: typeof AuthCallbackPayload.Type - }) { - return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) - }) + const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: { + params: { name: string } + payload: typeof AuthCallbackPayload.Type + }) { + return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code) + }) - const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { - if (!(yield* mcp.supportsOAuth(ctx.params.name))) { - return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) - } - return yield* mcp.authenticate(ctx.params.name) - }) + const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) { + if (!(yield* mcp.supportsOAuth(ctx.params.name))) { + return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` }) + } + return yield* mcp.authenticate(ctx.params.name) + }) - const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { - yield* mcp.removeAuth(ctx.params.name) - return { success: true as const } - }) + const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) { + yield* mcp.removeAuth(ctx.params.name) + return { success: true as const } + }) - const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { - yield* mcp.connect(ctx.params.name) - return true - }) + const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) { + yield* mcp.connect(ctx.params.name) + return true + }) - const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { - yield* mcp.disconnect(ctx.params.name) - return true - }) + const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) { + yield* mcp.disconnect(ctx.params.name) + return true + }) - return handlers - .handle("status", status) - .handle("add", add) - .handle("authStart", authStart) - .handle("authCallback", authCallback) - .handle("authAuthenticate", authAuthenticate) - .handle("authRemove", authRemove) - .handle("connect", connect) - .handle("disconnect", disconnect) - }), + return handlers + .handle("status", status) + .handle("add", add) + .handle("authStart", authStart) + .handle("authCallback", authCallback) + .handle("authAuthenticate", authAuthenticate) + .handle("authRemove", authRemove) + .handle("connect", connect) + .handle("disconnect", disconnect) + }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts index a5d6dab89514..2a7b6195dfd2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts @@ -5,25 +5,25 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => - Effect.gen(function* () { - const svc = yield* Permission.Service + Effect.gen(function* () { + const svc = yield* Permission.Service - const list = Effect.fn("PermissionHttpApi.list")(function* () { - return yield* svc.list() - }) + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) - const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { - params: { requestID: PermissionID } - payload: Permission.ReplyBody - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - reply: ctx.payload.reply, - message: ctx.payload.message, - }) - return true + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, }) + return true + }) - return handlers.handle("list", list).handle("reply", reply) - }), + return handlers.handle("list", list).handle("reply", reply) + }), ) 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 20a5ddfb09c3..ae2761ac32a7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -9,38 +9,38 @@ import { InstanceHttpApi } from "../api" import { markInstanceForReload } from "../lifecycle" export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) => - Effect.gen(function* () { - const svc = yield* Project.Service + Effect.gen(function* () { + const svc = yield* Project.Service - const list = Effect.fn("ProjectHttpApi.list")(function* () { - return yield* svc.list() - }) + const list = Effect.fn("ProjectHttpApi.list")(function* () { + return yield* svc.list() + }) - const current = Effect.fn("ProjectHttpApi.current")(function* () { - return (yield* InstanceState.context).project - }) + const current = Effect.fn("ProjectHttpApi.current")(function* () { + return (yield* InstanceState.context).project + }) - const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { - const ctx = yield* InstanceState.context - const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) - if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) - return next - yield* markInstanceForReload(ctx, { - directory: ctx.directory, - worktree: ctx.directory, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) + const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () { + const ctx = yield* InstanceState.context + const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project }) + if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree) return next + yield* markInstanceForReload(ctx, { + directory: ctx.directory, + worktree: ctx.directory, + project: next, + init: () => AppRuntime.runPromise(InstanceBootstrap), }) + return next + }) - const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { - params: { projectID: ProjectID } - payload: Project.UpdatePayload - }) { - return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) - }) + const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: { + params: { projectID: ProjectID } + payload: Project.UpdatePayload + }) { + return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }) + }) - return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) - }), + return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update) + }), ) 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 f343829d6aa1..c8689eabab9a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -10,80 +10,80 @@ import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) => - Effect.gen(function* () { - const cfg = yield* Config.Service - const provider = yield* Provider.Service - const svc = yield* ProviderAuth.Service + Effect.gen(function* () { + const cfg = yield* Config.Service + const provider = yield* Provider.Service + const svc = yield* ProviderAuth.Service - const list = Effect.fn("ProviderHttpApi.list")(function* () { - const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value - } - const connected = yield* provider.list() - const providers = Object.assign( - mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }) + const list = Effect.fn("ProviderHttpApi.list")(function* () { + const config = yield* cfg.get() + const all = yield* Effect.promise(() => ModelsDev.get()) + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const filtered: Record = {} + for (const [key, value] of Object.entries(all)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value + } + const connected = yield* provider.list() + const providers = Object.assign( + mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), + connected, + ) + return { + all: Object.values(providers), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(connected), + } + }) - const auth = Effect.fn("ProviderHttpApi.auth")(function* () { - return yield* svc.methods() - }) + const auth = Effect.fn("ProviderHttpApi.auth")(function* () { + return yield* svc.methods() + }) - const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.AuthorizeInput - }) { - return yield* svc - .authorize({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - inputs: ctx.payload.inputs, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - }) + const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.AuthorizeInput + }) { + return yield* svc + .authorize({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + inputs: ctx.payload.inputs, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + }) - const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { - params: { providerID: ProviderID } - request: HttpServerRequest.HttpServerRequest - }) { - const body = yield* Effect.orDie(ctx.request.text) - const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( - Effect.mapError(() => new HttpApiError.BadRequest({})), - ) - const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) - }) + const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: { + params: { providerID: ProviderID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + const result = yield* authorize({ params: ctx.params, payload }) + if (result === undefined) return HttpServerResponse.empty({ status: 200 }) + return HttpServerResponse.jsonUnsafe(result) + }) - const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { - params: { providerID: ProviderID } - payload: ProviderAuth.CallbackInput - }) { - yield* svc - .callback({ - providerID: ctx.params.providerID, - method: ctx.payload.method, - code: ctx.payload.code, - }) - .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) - return true - }) + const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { + params: { providerID: ProviderID } + payload: ProviderAuth.CallbackInput + }) { + yield* svc + .callback({ + providerID: ctx.params.providerID, + method: ctx.payload.method, + code: ctx.payload.code, + }) + .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({})))) + return true + }) - return handlers - .handle("list", list) - .handle("auth", auth) - .handleRaw("authorize", authorizeRaw) - .handle("callback", callback) - }), + return handlers + .handle("list", list) + .handle("auth", auth) + .handleRaw("authorize", authorizeRaw) + .handle("callback", callback) + }), ) 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 f2f17d4714c8..8558ee793cd4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -88,7 +88,9 @@ export const ptyConnectRoute = HttpRouter.add( }, send: (data: string | Uint8Array | ArrayBuffer) => { if (closed) return - Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void))) + Effect.runFork( + write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), + ) }, close: (code?: number, reason?: string) => { if (closed) return diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts index 53ca568cf5bd..3a4d316179c8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts @@ -5,29 +5,29 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) => - Effect.gen(function* () { - const svc = yield* Question.Service + Effect.gen(function* () { + const svc = yield* Question.Service - const list = Effect.fn("QuestionHttpApi.list")(function* () { - return yield* svc.list() - }) + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) - const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { - params: { requestID: QuestionID } - payload: Question.Reply - }) { - yield* svc.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, }) + return true + }) - const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { - yield* svc.reject(ctx.params.requestID) - return true - }) + const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) { + yield* svc.reject(ctx.params.requestID) + return true + }) - return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) - }), + return handlers.handle("list", list).handle("reply", reply).handle("reject", reject) + }), ) 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 d6264b605018..65c90b952995 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -25,7 +25,20 @@ import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session" +import { + CommandPayload, + DiffQuery, + ForkPayload, + InitPayload, + ListQuery, + MessagesQuery, + PermissionResponsePayload, + PromptPayload, + RevertPayload, + ShellPayload, + SummarizePayload, + UpdatePayload, +} from "../groups/session" const log = Log.create({ service: "server" }) @@ -88,40 +101,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - return yield* mapNotFound(Effect.gen(function* () { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before - yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.limit === undefined || ctx.query.limit === 0) { + return yield* mapNotFound( + Effect.gen(function* () { + if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } + if (ctx.query.limit === undefined || ctx.query.limit === 0) { + yield* session.get(ctx.params.sessionID) + return yield* session.messages({ sessionID: ctx.params.sessionID }) + } + yield* session.get(ctx.params.sessionID) - return yield* session.messages({ sessionID: ctx.params.sessionID }) - } - - yield* session.get(ctx.params.sessionID) - const page = MessageV2.page({ - sessionID: ctx.params.sessionID, - limit: ctx.query.limit, - before: ctx.query.before, - }) - if (!page.cursor) return page.items - - const request = yield* HttpServerRequest.HttpServerRequest - const url = 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, { - headers: { - "Access-Control-Expose-Headers": "Link, X-Next-Cursor", - Link: `<${url.toString()}>; rel="next"`, - "X-Next-Cursor": page.cursor, - }, - }) - })) + const page = MessageV2.page({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit, + before: ctx.query.before, + }) + if (!page.cursor) return page.items + + const request = yield* HttpServerRequest.HttpServerRequest + const url = 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, { + headers: { + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", + Link: `<${url.toString()}>; rel="next"`, + "X-Next-Cursor": page.cursor, + }, + }) + }), + ) }) const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts index 1ad42c526189..42e973020df5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts @@ -19,11 +19,12 @@ import * as Socket from "effect/unstable/socket/Socket" type HandlerEffect = Effect.Effect -export class InstanceContextMiddleware extends HttpApiMiddleware.Service()( - "@opencode/ExperimentalHttpApiInstanceContext", -) {} +export class InstanceContextMiddleware extends HttpApiMiddleware.Service< + InstanceContextMiddleware, + { + requires: Session.Service + } +>()("@opencode/ExperimentalHttpApiInstanceContext") {} function decode(input: string) { try { @@ -53,9 +54,14 @@ function requestHeaders(request: HttpServerRequest.HttpServerRequest) { return sourceRequest(request).headers } -function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, data: unknown) { +function writeSocket( + write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, + data: unknown, +) { if (data instanceof Blob) { - void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) + void data + .arrayBuffer() + .then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) return } if (typeof data === "string" || data instanceof Uint8Array) { @@ -78,7 +84,8 @@ function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: st queue.length = 0 } remote.onmessage = (event) => writeSocket(write, event.data) - remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) + remote.onerror = () => + Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) remote.onclose = (event) => Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) @@ -109,7 +116,9 @@ function proxyRemote( const url = workspaceProxyURL(target.url, requestURL) const source = sourceRequest(request) if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) - return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw)) + return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe( + Effect.map(HttpServerResponse.raw), + ) } function requestContext() { @@ -118,14 +127,19 @@ function requestContext() { ) } -function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) { +function provideRequestContext( + effect: HandlerEffect, + request: HttpServerRequest.HttpServerRequest, + sessionWorkspaceID?: WorkspaceID, +) { return Effect.gen(function* () { const url = new URL(request.url, "http://localhost") const headers = requestHeaders(request) const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined const workspaceParam = url.searchParams.get("workspace") const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) - const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined + const workspace = + workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined if (workspaceID && !workspace && !envWorkspaceID) { return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { @@ -134,7 +148,12 @@ function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest }) } - if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) { + if ( + workspace && + !isLocalWorkspaceRoute(request.method, url.pathname) && + !url.pathname.startsWith("/console") && + !envWorkspaceID + ) { const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) @@ -186,6 +205,8 @@ export const instanceContextLayer = Layer.succeed( InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), ) -export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => - requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), -)).layer +export const instanceRouterLayer = HttpRouter.middleware()( + Effect.succeed((effect) => + requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index e1c03e7bde38..c93261a0be87 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -14,7 +14,10 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response), + Effect.as( + Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), + response, + ), ) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index d9871c69be00..17d6e0d063f9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -126,8 +126,13 @@ function matchLegacyOpenApi(input: Record) { // Workspace creation fields `branch` and `extra` are Schema.NullOr — // genuinely nullable, not just optional. Re-add the null that the // component-level strip above removed. - const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "") - const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties + const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace( + "#/components/schemas/", + "", + ) + const properties = ref + ? spec.components?.schemas?.[ref]?.properties + : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } @@ -150,7 +155,10 @@ function matchLegacyOpenApi(input: Record) { description: "Event stream", content: { "text/event-stream": { - schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" }, + schema: + path === "/event" + ? { $ref: "#/components/schemas/Event" } + : { $ref: "#/components/schemas/GlobalEvent" }, }, }, } @@ -251,7 +259,8 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory) schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra) } - if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project) + if (schemas.GlobalSession?.properties?.project) + schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project) const providerOptions = schemas.ProviderConfig?.properties?.options if (providerOptions) providerOptions.additionalProperties = {} const model = schemas.ProviderConfig?.properties?.models?.additionalProperties @@ -486,12 +495,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = stripOptionalNull(param.schema) } -export const PublicApi = OpenCodeHttpApi - .annotateMerge( - OpenApi.annotations({ - title: "opencode", - version: "1.0.0", - description: "opencode api", - transform: matchLegacyOpenApi, - }), - ) +export const PublicApi = OpenCodeHttpApi.annotateMerge( + OpenApi.annotations({ + title: "opencode", + version: "1.0.0", + description: "opencode api", + transform: matchLegacyOpenApi, + }), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 40e709edd480..e4aeda798944 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,7 +38,9 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } -const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" }))) +const DefaultHono = lazy(() => + withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), +) const DefaultHttpApi = lazy(() => createDefaultHttpApi()) function select() { @@ -86,7 +88,10 @@ function createHttpApi() { } } -function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { +function createHono( + opts: { cors?: string[] }, + selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), +) { const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index b1a6ff403633..911f58efd0b9 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -461,7 +461,9 @@ type AssistantError = z.infer // Effect Schema for the same union — used by HttpApi OpenAPI generation. const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, - Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }), + Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ + identifier: "UnknownError", + }), OutputLengthError.EffectSchema, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 380225316c9a..2a6c02349fbb 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -11,8 +11,6 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) - - /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index a0324cce393a..5847192cb6aa 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -126,9 +126,9 @@ function requestBodyKey(spec: OpenApiSpec, body: unknown) { function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { if (!schema) return "" - const resolved = (schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema) as - | OpenApiSchema - | undefined + const resolved = ( + schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema + ) as OpenApiSchema | undefined if (resolved?.properties) return "object" if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" return resolved?.type ?? schema.type ?? "inline" diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index f430105714ca..5ce531dcc22e 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -214,7 +214,11 @@ describe("workspace HttpApi", () => { const workspace = await Instance.provide({ directory: tmp.path, fn: async () => { - registerAdaptor(Instance.project.id, "remote-target", remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base")) + registerAdaptor( + Instance.project.id, + "remote-target", + remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"), + ) return Workspace.create({ type: "remote-target", branch: null, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d79103df6456..bb6c74b838d1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1339,10 +1339,14 @@ "type": "object", "properties": { "rows": { - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, "cols": { - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["rows", "cols"] @@ -5595,10 +5599,14 @@ "required": ["text"] }, "line_number": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "absolute_offset": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "submatches": { "type": "array", @@ -5615,10 +5623,14 @@ "required": ["text"] }, "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["match", "start", "end"] @@ -6901,7 +6913,9 @@ }, "duration": { "description": "Duration in milliseconds", - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["message", "variant"] @@ -7621,13 +7635,19 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "updated": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "initialized": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created", "updated"] @@ -7913,10 +7933,14 @@ "type": "string" }, "additions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "deletions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "status": { "type": "string", @@ -8039,7 +8063,9 @@ "type": "string" }, "retries": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["message", "retries"] @@ -8083,7 +8109,9 @@ "type": "string" }, "statusCode": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "isRetryable": { "type": "boolean" @@ -8420,13 +8448,17 @@ "const": "retry" }, "attempt": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "message": { "type": "string" }, "next": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["type", "attempt", "message", "next"] @@ -8591,7 +8623,9 @@ }, "duration": { "description": "Duration in milliseconds", - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["message", "variant"] @@ -8777,7 +8811,9 @@ "enum": ["running", "exited"] }, "pid": { - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["id", "title", "command", "args", "cwd", "status", "pid"] @@ -8835,7 +8871,9 @@ "pattern": "^pty.*" }, "exitCode": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["id", "exitCode"] @@ -9024,7 +9062,9 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created"] @@ -9102,10 +9142,14 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "completed": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created"] @@ -9173,25 +9217,37 @@ "type": "object", "properties": { "total": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "input": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "output": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "reasoning": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "write": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["read", "write"] @@ -9311,10 +9367,14 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start"] @@ -9408,10 +9468,14 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start"] @@ -9427,12 +9491,12 @@ }, "start": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 }, "end": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 } }, @@ -9461,10 +9525,14 @@ "type": "object", "properties": { "line": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "character": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["line", "character"] @@ -9473,10 +9541,14 @@ "type": "object", "properties": { "line": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "character": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["line", "character"] @@ -9505,7 +9577,7 @@ }, "kind": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 } }, @@ -9625,7 +9697,9 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start"] @@ -9664,13 +9738,19 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "compacted": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start", "end"] @@ -9712,10 +9792,14 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start", "end"] @@ -9834,25 +9918,37 @@ "type": "object", "properties": { "total": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "input": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "output": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "reasoning": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "write": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["read", "write"] @@ -9949,12 +10045,12 @@ }, "start": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 }, "end": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 } }, @@ -9983,7 +10079,9 @@ "const": "retry" }, "attempt": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "error": { "$ref": "#/components/schemas/APIError" @@ -9992,7 +10090,9 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created"] @@ -10090,7 +10190,9 @@ "$ref": "#/components/schemas/Part" }, "time": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["sessionID", "part", "time"] @@ -10182,13 +10284,19 @@ "type": "object", "properties": { "additions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "deletions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "files": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "diffs": { "type": "array", @@ -10218,16 +10326,24 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "updated": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "compacting": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "archived": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created", "updated"] @@ -10434,7 +10550,9 @@ "$ref": "#/components/schemas/Part" }, "time": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["sessionID", "part", "time"] @@ -10631,13 +10749,19 @@ "type": "object", "properties": { "additions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "deletions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "files": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "diffs": { "type": "array", @@ -10694,7 +10818,9 @@ "created": { "anyOf": [ { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, { "type": "null" @@ -10704,7 +10830,9 @@ "updated": { "anyOf": [ { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, { "type": "null" @@ -10714,7 +10842,9 @@ "compacting": { "anyOf": [ { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, { "type": "null" @@ -10724,7 +10854,9 @@ "archived": { "anyOf": [ { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, { "type": "null" @@ -11481,7 +11613,9 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["type", "command"] @@ -11547,7 +11681,9 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "number" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": ["type", "url"] @@ -12055,7 +12191,9 @@ "type": "string" }, "expires": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "accountId": { "type": "string" @@ -12458,7 +12596,9 @@ "type": "string" }, "switchableOrgCount": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["consoleManagedProviders", "switchableOrgCount"] @@ -12579,13 +12719,19 @@ "type": "object", "properties": { "additions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "deletions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "files": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "diffs": { "type": "array", @@ -12615,16 +12761,24 @@ "type": "object", "properties": { "created": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "updated": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "compacting": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "archived": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["created", "updated"] @@ -12710,10 +12864,14 @@ "type": "object", "properties": { "start": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "end": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } }, "required": ["start"] @@ -12776,12 +12934,12 @@ }, "start": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 }, "end": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 } }, @@ -12956,7 +13114,9 @@ "type": "string" }, "kind": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "location": { "type": "object", @@ -13029,16 +13189,24 @@ "type": "object", "properties": { "oldStart": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "oldLines": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "newStart": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "newLines": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "lines": { "type": "array", @@ -13074,12 +13242,12 @@ }, "added": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 }, "removed": { "type": "integer", - "minimum": -9007199254740991, + "minimum": 0, "maximum": 9007199254740991 }, "status": { @@ -13360,10 +13528,14 @@ "type": "string" }, "additions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "deletions": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "status": { "type": "string", From d3df8e118066b941628e4f6aa9ac8c5939df62a7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 09:46:17 -0400 Subject: [PATCH 0003/1114] test(httpapi): clean up SDK parity tests --- .../opencode/test/server/httpapi-sdk.test.ts | 588 +++++++++--------- 1 file changed, 300 insertions(+), 288 deletions(-) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index c0984170be5c..e96ea6c889f2 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" +import type * as Scope from "effect/Scope" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" @@ -7,20 +8,26 @@ import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { ModelID, ProviderID } from "../../src/provider/schema" +import type { Config } from "@/config/config" 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 { it } from "../lib/effect" const original = { OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } + type Backend = "legacy" | "httpapi" type Sdk = ReturnType type SdkResult = { response: Response; data?: unknown; error?: unknown } +type Captured = { status: number; data?: unknown; error?: unknown } +type ProjectFixture = { sdk: Sdk; directory: string } +type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } function app(backend: Backend, input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" @@ -85,17 +92,35 @@ function providerConfig(url: string) { } } -async function expectStatus(result: Promise<{ response: Response }>, status: number) { - expect((await result).response.status).toBe(status) +function call(request: () => Promise) { + return Effect.promise(request) } -async function capture(result: Promise) { - const response = await result - return { - status: response.response.status, - data: response.data, - error: response.error, - } +function capture(request: () => Promise) { + return call(request).pipe( + Effect.map((result) => ({ + status: result.response.status, + data: result.data, + error: result.error, + })), + ) +} + +function expectStatus(request: () => Promise<{ response: Response }>, status: number) { + return call(request).pipe( + Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))), + Effect.asVoid, + ) +} + +function firstEvent(open: () => Promise<{ stream: AsyncIterator }>) { + return Effect.acquireRelease( + call(open), + (events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((events) => call(() => events.stream.next())), + Effect.map((result) => result.value), + ) } function record(value: unknown) { @@ -106,7 +131,7 @@ function array(value: unknown) { return Array.isArray(value) ? value : [] } -function statuses(input: Record>>) { +function statuses(input: Record) { return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status])) } @@ -121,75 +146,91 @@ function sessionTitles(value: unknown) { .sort() } -async function runSession(directory: string, effect: Effect.Effect) { - return Instance.provide({ - directory, - fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))), +function resetState() { + return Effect.promise(async () => { + await Instance.disposeAll() + await resetDatabase() }) } -async function seedMessage(directory: string, sessionID: string) { - const id = SessionID.make(sessionID) - return runSession( - directory, - SessionNs.Service.use((svc) => - Effect.gen(function* () { - const message = yield* svc.updateMessage({ - id: MessageID.ascending(), - sessionID: id, - role: "user", - time: { created: Date.now() }, - agent: "test", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - tools: {}, - mode: "", - } as unknown as MessageV2.Info) - const part = yield* svc.updatePart({ - id: PartID.ascending(), - sessionID: id, - messageID: message.id, - type: "text", - text: "seeded message", - }) - return { message, part } - }), - ), +function httpapi(name: string, effect: Effect.Effect) { + it.live(name, effect) +} + +function parity(name: string, scenario: (backend: Backend) => Effect.Effect) { + it.live( + name, + Effect.gen(function* () { + const legacy = yield* scenario("legacy") + yield* resetState() + const httpapi = yield* scenario("httpapi") + expect(httpapi).toEqual(legacy) + }), ) } -async function compareBackends(scenario: (backend: Backend) => Promise) { - const legacy = await scenario("legacy") - await Instance.disposeAll() - await resetDatabase() - const httpapi = await scenario("httpapi") - expect(httpapi).toEqual(legacy) +function withProject( + backend: Backend, + options: { git?: boolean; config?: Partial; setup?: (dir: string) => Effect.Effect }, + run: (input: ProjectFixture) => Effect.Effect, +) { + return Effect.acquireRelease( + call(() => tmpdir({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })), + (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore), + ).pipe( + Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void), + Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })), + ) } -async function withTmp(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise) { - await using tmp = await tmpdir({ - git: true, - config: { formatter: false, lsp: false }, - init: async (dir) => { - await Bun.write(path.join(dir, "hello.txt"), "hello") - await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n") - }, - }) - return fn({ sdk: client(backend, tmp.path), directory: tmp.path }) +function withStandardProject(backend: Backend, run: (input: ProjectFixture) => Effect.Effect) { + return withProject(backend, { setup: writeStandardFiles }, run) } -async function withFakeLlm( - backend: Backend, - fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise, -) { - return Effect.runPromise( - Effect.gen(function* () { - const llm = yield* TestLLMServer - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ) - return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm })) - }).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)), +function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect) { + return Effect.gen(function* () { + const llm = yield* TestLLMServer + return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + }).pipe(Effect.provide(TestLLMServer.layer)) +} + +function writeStandardFiles(dir: string) { + return Effect.all([ + call(() => Bun.write(path.join(dir, "hello.txt"), "hello")), + call(() => Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")), + ]).pipe(Effect.asVoid) +} + +function seedMessage(directory: string, sessionID: string) { + const id = SessionID.make(sessionID) + return call(async () => + await Instance.provide({ + directory, + fn: () => + Effect.runPromise( + SessionNs.Service.use((svc) => + Effect.gen(function* () { + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + sessionID: id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + tools: {}, + } satisfies MessageV2.User) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: id, + messageID: message.id, + type: "text", + text: "seeded message", + }) + return { message, part } + }), + ).pipe(Effect.provide(SessionNs.defaultLayer)), + ), + }), ) } @@ -202,113 +243,91 @@ afterEach(async () => { }) describe("HttpApi SDK", () => { - test("uses the generated SDK for global and control routes", async () => { - const sdk = client("httpapi") - const health = await sdk.global.health() - - expect(health.response.status).toBe(200) - expect(health.data).toMatchObject({ healthy: true }) - - const events = await sdk.global.event({ signal: AbortSignal.timeout(1_000) }) - try { - const first = await events.stream.next() - expect(first.value).toMatchObject({ payload: { type: "server.connected" } }) - } finally { - await events.stream.return(undefined) - } - - const log = await sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }) - expect(log.response.status).toBe(200) - expect(log.data).toBe(true) - - await expectStatus(sdk.auth.set({ providerID: "test" }), 400) - }) + httpapi( + "uses the generated SDK for global and control routes", + Effect.gen(function* () { + const sdk = client("httpapi") + const health = yield* call(() => sdk.global.health()) + const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) + + expect(health.response.status).toBe(200) + expect(health.data).toMatchObject({ healthy: true }) + expect(yield* firstEvent(() => sdk.global.event({ signal: AbortSignal.timeout(1_000) }))).toMatchObject({ + payload: { type: "server.connected" }, + }) + expect(log.response.status).toBe(200) + expect(log.data).toBe(true) + yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400) + }), + ) - test("uses the generated SDK for safe instance routes", async () => { - await using tmp = await tmpdir({ - config: { formatter: false, lsp: false }, - init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"), - }) - const sdk = client("httpapi", tmp.path) - - const file = await sdk.file.read({ path: "hello.txt" }) - expect(file.response.status).toBe(200) - expect(file.data).toMatchObject({ content: "hello" }) - - const session = await sdk.session.create({ title: "sdk" }) - expect(session.response.status).toBe(200) - expect(session.data).toMatchObject({ title: "sdk" }) - - const listed = await sdk.session.list({ roots: true, limit: 10 }) - expect(listed.response.status).toBe(200) - expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) - - await Promise.all([ - expectStatus(sdk.project.current(), 200), - expectStatus(sdk.config.get(), 200), - expectStatus(sdk.config.providers(), 200), - expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200), - ]) - }) + httpapi( + "uses the generated SDK for safe instance routes", + withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) => + Effect.gen(function* () { + const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) + const session = yield* call(() => sdk.session.create({ title: "sdk" })) + const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 })) + + expect(file.response.status).toBe(200) + expect(file.data).toMatchObject({ content: "hello" }) + expect(session.response.status).toBe(200) + expect(session.data).toMatchObject({ title: "sdk" }) + expect(listed.response.status).toBe(200) + expect(listed.data?.map((item) => item.id)).toContain(session.data?.id) + + yield* Effect.all([ + expectStatus(() => sdk.project.current(), 200), + expectStatus(() => sdk.config.get(), 200), + expectStatus(() => sdk.config.providers(), 200), + expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200), + ]) + }), + ), + ) - test("matches generated SDK global and control behavior across backends", async () => { - await compareBackends(async (backend) => { + parity("matches generated SDK global and control behavior across backends", (backend) => + Effect.gen(function* () { const sdk = client(backend) - const health = await capture(sdk.global.health()) - const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) - const invalidAuth = await capture(sdk.auth.set({ providerID: "test" })) + const health = yield* capture(() => sdk.global.health()) + const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) + const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) return { statuses: statuses({ health, log, invalidAuth }), health: record(health.data).healthy, log: log.data, } - }) - }) + }), + ) - test("matches generated SDK global event stream across backends", async () => { - await compareBackends(async (backend) => { - const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) }) - try { - const first = await events.stream.next() - return { - type: record(record(first.value).payload).type, - } - } finally { - await events.stream.return(undefined) - } - }) - }) + parity("matches generated SDK global event stream across backends", (backend) => + firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( + Effect.map((event) => ({ type: record(record(event).payload).type })), + ), + ) - test("matches generated SDK instance event stream across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk }) => { - const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) }) - try { - const first = await events.stream.next() - return { - type: record(record(first.value).payload).type, - } - } finally { - await events.stream.return(undefined) - } - }), - ) - }) + parity("matches generated SDK instance event stream across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe( + Effect.map((event) => ({ type: record(record(event).payload).type })), + ), + ), + ) - test("matches generated SDK basic auth behavior across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ directory }) => { - const missing = await capture( + parity("matches generated SDK basic auth behavior across backends", (backend) => + withStandardProject(backend, ({ directory }) => + Effect.gen(function* () { + const missing = yield* capture(() => client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), ) - const bad = await capture( + const bad = yield* capture(() => client(backend, directory, { password: "secret", headers: { authorization: authorization("opencode", "wrong") }, }).file.read({ path: "hello.txt" }), ) - const good = await capture( + const good = yield* capture(() => client(backend, directory, { password: "secret", headers: { authorization: authorization("opencode", "secret") }, @@ -320,28 +339,28 @@ describe("HttpApi SDK", () => { content: record(good.data).content, } }), - ) - }) + ), + ) - test("matches generated SDK instance read routes across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk, directory }) => { - const project = await capture(sdk.project.current()) - const projects = await capture(sdk.project.list()) - const paths = await capture(sdk.path.get()) - const config = await capture(sdk.config.get()) - const providers = await capture(sdk.config.providers()) - const file = await capture(sdk.file.read({ path: "hello.txt" })) - const files = await capture(sdk.file.list({ path: "." })) - const fileStatus = await capture(sdk.file.status()) - const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 })) - const findText = await capture(sdk.find.text({ pattern: "sdk-parity" })) - const agents = await capture(sdk.app.agents()) - const skills = await capture(sdk.app.skills()) - const tools = await capture(sdk.tool.ids()) - const vcs = await capture(sdk.vcs.get()) - const formatter = await capture(sdk.formatter.status()) - const lsp = await capture(sdk.lsp.status()) + parity("matches generated SDK instance read routes across backends", (backend) => + withStandardProject(backend, ({ sdk, directory }) => + Effect.gen(function* () { + const project = yield* capture(() => sdk.project.current()) + const projects = yield* capture(() => sdk.project.list()) + const paths = yield* capture(() => sdk.path.get()) + const config = yield* capture(() => sdk.config.get()) + const providers = yield* capture(() => sdk.config.providers()) + const file = yield* capture(() => sdk.file.read({ path: "hello.txt" })) + const files = yield* capture(() => sdk.file.list({ path: "." })) + const fileStatus = yield* capture(() => sdk.file.status()) + const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 })) + const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" })) + const agents = yield* capture(() => sdk.app.agents()) + const skills = yield* capture(() => sdk.app.skills()) + const tools = yield* capture(() => sdk.tool.ids()) + const vcs = yield* capture(() => sdk.vcs.get()) + const formatter = yield* capture(() => sdk.formatter.status()) + const lsp = yield* capture(() => sdk.lsp.status()) return { statuses: statuses({ @@ -362,12 +381,8 @@ describe("HttpApi SDK", () => { formatter, lsp, }), - project: { - worktreeSelected: record(project.data).worktree === directory, - }, - paths: { - cwdSelected: record(paths.data).cwd === directory, - }, + project: { worktreeSelected: record(project.data).worktree === directory }, + paths: { cwdSelected: record(paths.data).cwd === directory }, file: record(file.data).content, hasProject: array(projects.data).length > 0, foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), @@ -375,29 +390,29 @@ describe("HttpApi SDK", () => { listedFile: JSON.stringify(files.data).includes("hello.txt"), } }), - ) - }) + ), + ) - test("matches generated SDK session lifecycle routes across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk }) => { - const parent = await capture(sdk.session.create({ title: "parent" })) + parity("matches generated SDK session lifecycle routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + Effect.gen(function* () { + const parent = yield* capture(() => sdk.session.create({ title: "parent" })) const parentID = String(record(parent.data).id) - const child = await capture(sdk.session.create({ title: "child", parentID })) + const child = yield* capture(() => sdk.session.create({ title: "child", parentID })) const childID = String(record(child.data).id) - const get = await capture(sdk.session.get({ sessionID: parentID })) - const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" })) - const roots = await capture(sdk.session.list({ roots: true, limit: 10 })) - const all = await capture(sdk.session.list({ roots: false, limit: 10 })) - const children = await capture(sdk.session.children({ sessionID: parentID })) - const todo = await capture(sdk.session.todo({ sessionID: parentID })) - const status = await capture(sdk.session.status()) - const messages = await capture(sdk.session.messages({ sessionID: parentID })) - const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" })) - const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) - const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) - const deleted = await capture(sdk.session.delete({ sessionID: childID })) - const getDeleted = await capture(sdk.session.get({ sessionID: childID })) + const get = yield* capture(() => sdk.session.get({ sessionID: parentID })) + const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" })) + const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 })) + const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 })) + const children = yield* capture(() => sdk.session.children({ sessionID: parentID })) + const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID })) + const status = yield* capture(() => sdk.session.status()) + const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) + const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) + const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) + const invalidCursor = yield* capture(() => sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) + const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID })) + const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID })) return { statuses: statuses({ @@ -426,36 +441,33 @@ describe("HttpApi SDK", () => { messageCount: array(messages.data).length, } }), - ) - }) + ), + ) - test("matches generated SDK session message and part routes across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk, directory }) => { - const session = await capture(sdk.session.create({ title: "messages" })) + parity("matches generated SDK session message and part routes across backends", (backend) => + withStandardProject(backend, ({ sdk, directory }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "messages" })) const sessionID = String(record(session.data).id) - const seeded = await seedMessage(directory, sessionID) - const list = await capture(sdk.session.messages({ sessionID })) - const page = await capture(sdk.session.messages({ sessionID, limit: 1 })) - const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) - const partUpdate = await capture( + const seeded = yield* seedMessage(directory, sessionID) + const list = yield* capture(() => sdk.session.messages({ sessionID })) + const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 })) + const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partUpdate = yield* capture(() => sdk.part.update({ sessionID, messageID: seeded.message.id, partID: seeded.part.id, - part: { - ...seeded.part, - text: "updated message", - } as NonNullable[0]["part"]>, + part: { ...seeded.part, text: "updated message" } as NonNullable[0]["part"]>, }), ) - const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) - const partDelete = await capture( + const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const partDelete = yield* capture(() => sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), ) - const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) - const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) - const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) + const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) + const deleteMessage = yield* capture(() => sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) + const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) return { statuses: statuses({ @@ -477,15 +489,15 @@ describe("HttpApi SDK", () => { partCountAfterDelete: array(record(withoutPart.data).parts).length, } }), - ) - }) + ), + ) - test("matches generated SDK prompt no-reply routes across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk }) => { - const session = await capture(sdk.session.create({ title: "prompt" })) + parity("matches generated SDK prompt no-reply routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "prompt" })) const sessionID = String(record(session.data).id) - const prompt = await capture( + const prompt = yield* capture(() => sdk.session.prompt({ sessionID, agent: "build", @@ -493,7 +505,7 @@ describe("HttpApi SDK", () => { parts: [{ type: "text", text: "hello" }], }), ) - const asyncPrompt = await capture( + const asyncPrompt = yield* capture(() => sdk.session.promptAsync({ sessionID, agent: "build", @@ -501,7 +513,7 @@ describe("HttpApi SDK", () => { parts: [{ type: "text", text: "async hello" }], }), ) - const messages = await capture(sdk.session.messages({ sessionID })) + const messages = yield* capture(() => sdk.session.messages({ sessionID })) return { statuses: statuses({ session, prompt, asyncPrompt, messages }), @@ -514,21 +526,21 @@ describe("HttpApi SDK", () => { .sort(), } }), - ) - }) + ), + ) - test("matches generated SDK prompt streaming through fake LLM across backends", async () => { - await compareBackends((backend) => - withFakeLlm(backend, async ({ sdk, llm }) => { - await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } })) - const session = await capture( + parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) => + withFakeLlm(backend, ({ sdk, llm }) => + Effect.gen(function* () { + yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) + const session = yield* capture(() => sdk.session.create({ title: "llm prompt", permission: [{ permission: "*", pattern: "*", action: "allow" }], }), ) const sessionID = String(record(session.data).id) - const prompt = await capture( + const prompt = yield* capture(() => sdk.session.prompt({ sessionID, agent: "build", @@ -536,8 +548,8 @@ describe("HttpApi SDK", () => { parts: [{ type: "text", text: "hello llm" }], }), ) - const messages = await capture(sdk.session.messages({ sessionID })) - const inputs = await Effect.runPromise(llm.inputs) + const messages = yield* capture(() => sdk.session.messages({ sessionID })) + const inputs = yield* llm.inputs return { statuses: statuses({ session, prompt, messages }), @@ -548,26 +560,26 @@ describe("HttpApi SDK", () => { userText: JSON.stringify(messages.data).includes("hello llm"), } }), - ) - }) + ), + ) - test("matches generated SDK TUI validation and command routes across backends", async () => { - await compareBackends((backend) => - withTmp(backend, async ({ sdk }) => { - const session = await capture(sdk.session.create({ title: "tui" })) + parity("matches generated SDK TUI validation and command routes across backends", (backend) => + withStandardProject(backend, ({ sdk }) => + Effect.gen(function* () { + const session = yield* capture(() => sdk.session.create({ title: "tui" })) const sessionID = String(record(session.data).id) - const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" })) - const openHelp = await capture(sdk.tui.openHelp()) - const openSessions = await capture(sdk.tui.openSessions()) - const openThemes = await capture(sdk.tui.openThemes()) - const openModels = await capture(sdk.tui.openModels()) - const submitPrompt = await capture(sdk.tui.submitPrompt()) - const clearPrompt = await capture(sdk.tui.clearPrompt()) - const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" })) - const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) - const selectSession = await capture(sdk.tui.selectSession({ sessionID })) - const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" })) - const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" })) + const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" })) + const openHelp = yield* capture(() => sdk.tui.openHelp()) + const openSessions = yield* capture(() => sdk.tui.openSessions()) + const openThemes = yield* capture(() => sdk.tui.openThemes()) + const openModels = yield* capture(() => sdk.tui.openModels()) + const submitPrompt = yield* capture(() => sdk.tui.submitPrompt()) + const clearPrompt = yield* capture(() => sdk.tui.clearPrompt()) + const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" })) + const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) + const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID })) + const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" })) + const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" })) return { statuses: statuses({ @@ -599,32 +611,32 @@ describe("HttpApi SDK", () => { }, } }), - ) - }) + ), + ) - test("matches generated SDK project git initialization across backends", async () => { - await compareBackends(async (backend) => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const sdk = client(backend, tmp.path) - const before = await capture(sdk.project.current()) - const init = await capture(sdk.project.initGit()) - const after = await capture(sdk.project.current()) + parity("matches generated SDK project git initialization across backends", (backend) => + withProject(backend, { git: false }, ({ sdk, directory }) => + Effect.gen(function* () { + const before = yield* capture(() => sdk.project.current()) + const init = yield* capture(() => sdk.project.initGit()) + const after = yield* capture(() => sdk.project.current()) - return { - statuses: statuses({ before, init, after }), - before: { - vcs: record(before.data).vcs ?? null, - worktree: record(before.data).worktree, - }, - init: { - vcs: record(init.data).vcs, - worktreeSelected: record(init.data).worktree === tmp.path, - }, - after: { - vcs: record(after.data).vcs, - worktreeSelected: record(after.data).worktree === tmp.path, - }, - } - }) - }) + return { + statuses: statuses({ before, init, after }), + before: { + vcs: record(before.data).vcs ?? null, + worktree: record(before.data).worktree, + }, + init: { + vcs: record(init.data).vcs, + worktreeSelected: record(init.data).worktree === directory, + }, + after: { + vcs: record(after.data).vcs, + worktreeSelected: record(after.data).worktree === directory, + }, + } + }), + ), + ) }) From a3f7ea25551d9d206d947d4d0a16739609208fb0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Apr 2026 13:47:48 +0000 Subject: [PATCH 0004/1114] chore: generate --- .../opencode/test/server/httpapi-sdk.test.ts | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index e96ea6c889f2..66f48455a02b 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -114,9 +114,8 @@ function expectStatus(request: () => Promise<{ response: Response }>, status: nu } function firstEvent(open: () => Promise<{ stream: AsyncIterator }>) { - return Effect.acquireRelease( - call(open), - (events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore), + return Effect.acquireRelease(call(open), (events) => + call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore), ).pipe( Effect.flatMap((events) => call(() => events.stream.next())), Effect.map((result) => result.value), @@ -203,34 +202,35 @@ function writeStandardFiles(dir: string) { function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) - return call(async () => - await Instance.provide({ - directory, - fn: () => - Effect.runPromise( - SessionNs.Service.use((svc) => - Effect.gen(function* () { - const message = yield* svc.updateMessage({ - id: MessageID.ascending(), - sessionID: id, - role: "user", - time: { created: Date.now() }, - agent: "test", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - tools: {}, - } satisfies MessageV2.User) - const part = yield* svc.updatePart({ - id: PartID.ascending(), - sessionID: id, - messageID: message.id, - type: "text", - text: "seeded message", - }) - return { message, part } - }), - ).pipe(Effect.provide(SessionNs.defaultLayer)), - ), - }), + return call( + async () => + await Instance.provide({ + directory, + fn: () => + Effect.runPromise( + SessionNs.Service.use((svc) => + Effect.gen(function* () { + const message = yield* svc.updateMessage({ + id: MessageID.ascending(), + sessionID: id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + tools: {}, + } satisfies MessageV2.User) + const part = yield* svc.updatePart({ + id: PartID.ascending(), + sessionID: id, + messageID: message.id, + type: "text", + text: "seeded message", + }) + return { message, part } + }), + ).pipe(Effect.provide(SessionNs.defaultLayer)), + ), + }), ) } @@ -410,7 +410,9 @@ describe("HttpApi SDK", () => { const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID })) const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" })) const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) - const invalidCursor = yield* capture(() => sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) + const invalidCursor = yield* capture(() => + sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }), + ) const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID })) const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID })) @@ -458,7 +460,9 @@ describe("HttpApi SDK", () => { sessionID, messageID: seeded.message.id, partID: seeded.part.id, - part: { ...seeded.part, text: "updated message" } as NonNullable[0]["part"]>, + part: { ...seeded.part, text: "updated message" } as NonNullable< + Parameters[0]["part"] + >, }), ) const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) @@ -466,7 +470,9 @@ describe("HttpApi SDK", () => { sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), ) const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) - const deleteMessage = yield* capture(() => sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) + const deleteMessage = yield* capture(() => + sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }), + ) const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id })) return { From 71f91896077fb02e6a086ef1ff402b3e093d05bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:54:34 +0000 Subject: [PATCH 0005/1114] Update VOUCHED list https://github.com/anomalyco/opencode/issues/24964#issuecomment-4345349260 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index bc1ce1cd2dc5..57e03ebb9a6a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -17,6 +17,7 @@ ariane-emory -danieljoshuanazareth -davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person edemaine +fahreddinozcan -florianleibert fwang iamdavidhill From 00bb9836a60f1dcdd0ce5078b05d12f749fdde66 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:55:53 -0500 Subject: [PATCH 0006/1114] tweak: adjust order of system prompt instructions: Global, Project, Skills (#24974) --- packages/opencode/src/session/instruction.ts | 14 +++++++------- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/test/session/instruction.test.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index bd5098015906..5d91066b41f8 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -122,6 +122,13 @@ export const layer: Layer.Layer() + for (const file of globalFiles()) { + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + break + } + } + // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of FILES) { @@ -133,13 +140,6 @@ export const layer: Layer.Layer { const rules = yield* svc.system() expect(rules).toHaveLength(2) - expect(rules).toContain( - `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, - ) - expect(rules).toContain( + expect(rules[0]).toBe( `Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`, ) + expect(rules[1]).toBe( + `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, + ) }), ), ), From 6aa8e894b1cc770e099f8db4338508d1b5d25c04 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:15:44 -0500 Subject: [PATCH 0007/1114] chore: rm broken codesearch tool (#24992) --- packages/app/src/i18n/ar.ts | 2 - packages/app/src/i18n/br.ts | 2 - packages/app/src/i18n/bs.ts | 2 - packages/app/src/i18n/da.ts | 2 - packages/app/src/i18n/de.ts | 2 - packages/app/src/i18n/en.ts | 2 - packages/app/src/i18n/es.ts | 2 - packages/app/src/i18n/fr.ts | 2 - packages/app/src/i18n/ja.ts | 2 - packages/app/src/i18n/ko.ts | 2 - packages/app/src/i18n/no.ts | 2 - packages/app/src/i18n/pl.ts | 2 - packages/app/src/i18n/ru.ts | 2 - packages/app/src/i18n/th.ts | 2 - packages/app/src/i18n/tr.ts | 2 - packages/app/src/i18n/zh.ts | 2 - packages/app/src/i18n/zht.ts | 2 - packages/opencode/specs/effect/schema.md | 1 - packages/opencode/specs/effect/tools.md | 2 - packages/opencode/src/agent/agent.ts | 1 - packages/opencode/src/cli/cmd/agent.ts | 1 - packages/opencode/src/cli/cmd/run.ts | 9 --- .../src/cli/cmd/tui/routes/session/index.tsx | 13 ---- .../cli/cmd/tui/routes/session/permission.tsx | 15 ----- packages/opencode/src/config/permission.ts | 1 - .../src/session/prompt/copilot-gpt-5.txt | 2 +- packages/opencode/src/tool/codesearch.ts | 62 ------------------- packages/opencode/src/tool/codesearch.txt | 12 ---- packages/opencode/src/tool/mcp-exa.ts | 5 -- packages/opencode/src/tool/registry.ts | 6 +- .../__snapshots__/parameters.test.ts.snap | 23 ------- .../opencode/test/tool/parameters.test.ts | 17 ----- packages/sdk/js/src/v2/gen/types.gen.ts | 1 - packages/sdk/openapi.json | 3 - packages/ui/src/components/message-part.tsx | 32 ---------- .../components/tool-error-card.stories.tsx | 6 +- .../ui/src/components/tool-error-card.tsx | 1 - packages/ui/src/i18n/ar.ts | 1 - packages/ui/src/i18n/br.ts | 1 - packages/ui/src/i18n/bs.ts | 1 - packages/ui/src/i18n/da.ts | 1 - packages/ui/src/i18n/de.ts | 1 - packages/ui/src/i18n/en.ts | 1 - packages/ui/src/i18n/es.ts | 1 - packages/ui/src/i18n/fr.ts | 1 - packages/ui/src/i18n/ja.ts | 1 - packages/ui/src/i18n/ko.ts | 1 - packages/ui/src/i18n/no.ts | 1 - packages/ui/src/i18n/pl.ts | 1 - packages/ui/src/i18n/ru.ts | 1 - packages/ui/src/i18n/th.ts | 1 - packages/ui/src/i18n/tr.ts | 1 - packages/ui/src/i18n/zh.ts | 1 - packages/ui/src/i18n/zht.ts | 1 - packages/web/src/content/docs/agents.mdx | 1 - .../web/src/content/docs/ar/permissions.mdx | 2 +- .../web/src/content/docs/bs/permissions.mdx | 2 +- packages/web/src/content/docs/cli.mdx | 2 +- .../web/src/content/docs/da/permissions.mdx | 2 +- .../web/src/content/docs/de/permissions.mdx | 2 +- .../web/src/content/docs/es/permissions.mdx | 2 +- .../web/src/content/docs/fr/permissions.mdx | 2 +- .../web/src/content/docs/it/permissions.mdx | 2 +- .../web/src/content/docs/ja/permissions.mdx | 2 +- .../web/src/content/docs/ko/permissions.mdx | 2 +- .../web/src/content/docs/nb/permissions.mdx | 2 +- packages/web/src/content/docs/permissions.mdx | 2 +- .../web/src/content/docs/pl/permissions.mdx | 2 +- .../src/content/docs/pt-br/permissions.mdx | 2 +- .../web/src/content/docs/ru/permissions.mdx | 2 +- .../web/src/content/docs/th/permissions.mdx | 2 +- .../web/src/content/docs/tr/permissions.mdx | 2 +- .../src/content/docs/zh-cn/permissions.mdx | 2 +- .../src/content/docs/zh-tw/permissions.mdx | 2 +- 74 files changed, 22 insertions(+), 281 deletions(-) delete mode 100644 packages/opencode/src/tool/codesearch.ts delete mode 100644 packages/opencode/src/tool/codesearch.txt diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 702210e4d36f..49808f3fbbe3 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -721,8 +721,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL", "settings.permissions.tool.websearch.title": "بحث الويب", "settings.permissions.tool.websearch.description": "البحث في الويب", - "settings.permissions.tool.codesearch.title": "بحث الكود", - "settings.permissions.tool.codesearch.description": "البحث عن كود على الويب", "settings.permissions.tool.external_directory.title": "دليل خارجي", "settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع", "settings.permissions.tool.doom_loop.title": "حلقة الموت", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b414fff36e3a..5b96ff57a659 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -732,8 +732,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL", "settings.permissions.tool.websearch.title": "Pesquisa Web", "settings.permissions.tool.websearch.description": "Pesquisar na web", - "settings.permissions.tool.codesearch.title": "Pesquisa de Código", - "settings.permissions.tool.codesearch.description": "Pesquisar código na web", "settings.permissions.tool.external_directory.title": "Diretório Externo", "settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto", "settings.permissions.tool.doom_loop.title": "Loop Infinito", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e316f87da250..98e565464be2 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -806,8 +806,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a", "settings.permissions.tool.websearch.title": "Web pretraga", "settings.permissions.tool.websearch.description": "Pretražuj web", - "settings.permissions.tool.codesearch.title": "Pretraga koda", - "settings.permissions.tool.codesearch.description": "Pretraži kod na webu", "settings.permissions.tool.external_directory.title": "Vanjski direktorij", "settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta", "settings.permissions.tool.doom_loop.title": "Beskonačna petlja", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index d368f292d2ea..5c1dcfcae409 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -800,8 +800,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Hent indhold fra en URL", "settings.permissions.tool.websearch.title": "Websøgning", "settings.permissions.tool.websearch.description": "Søg på nettet", - "settings.permissions.tool.codesearch.title": "Kodesøgning", - "settings.permissions.tool.codesearch.description": "Søg kode på nettet", "settings.permissions.tool.external_directory.title": "Ekstern mappe", "settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a2b049c8800a..68a4def36ada 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -743,8 +743,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen", "settings.permissions.tool.websearch.title": "Web-Suche", "settings.permissions.tool.websearch.description": "Das Web durchsuchen", - "settings.permissions.tool.codesearch.title": "Code-Suche", - "settings.permissions.tool.codesearch.description": "Code im Web durchsuchen", "settings.permissions.tool.external_directory.title": "Externes Verzeichnis", "settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index eae5aeb949f8..1d2b03db76d3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -920,8 +920,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Fetch content from a URL", "settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.description": "Search the web", - "settings.permissions.tool.codesearch.title": "Code Search", - "settings.permissions.tool.codesearch.description": "Search code on the web", "settings.permissions.tool.external_directory.title": "External Directory", "settings.permissions.tool.external_directory.description": "Access files outside the project directory", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 8dc644bb8e41..4761cd2000b2 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -813,8 +813,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Obtener contenido de una URL", "settings.permissions.tool.websearch.title": "Búsqueda Web", "settings.permissions.tool.websearch.description": "Buscar en la web", - "settings.permissions.tool.codesearch.title": "Búsqueda de Código", - "settings.permissions.tool.codesearch.description": "Buscar código en la web", "settings.permissions.tool.external_directory.title": "Directorio Externo", "settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto", "settings.permissions.tool.doom_loop.title": "Bucle Infinito", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1b4916c7d9db..f470cace42ec 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -741,8 +741,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL", "settings.permissions.tool.websearch.title": "Recherche Web", "settings.permissions.tool.websearch.description": "Rechercher sur le web", - "settings.permissions.tool.codesearch.title": "Recherche de code", - "settings.permissions.tool.codesearch.description": "Rechercher du code sur le web", "settings.permissions.tool.external_directory.title": "Répertoire externe", "settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet", "settings.permissions.tool.doom_loop.title": "Boucle infernale", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 979f94203d5a..51eab3d09b76 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -727,8 +727,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "URLからコンテンツを取得", "settings.permissions.tool.websearch.title": "Web検索", "settings.permissions.tool.websearch.description": "ウェブを検索", - "settings.permissions.tool.codesearch.title": "コード検索", - "settings.permissions.tool.codesearch.description": "ウェブ上のコードを検索", "settings.permissions.tool.external_directory.title": "外部ディレクトリ", "settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス", "settings.permissions.tool.doom_loop.title": "無限ループ", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 56ce374a9621..206ae23d82ea 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -722,8 +722,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기", "settings.permissions.tool.websearch.title": "웹 검색", "settings.permissions.tool.websearch.description": "웹 검색", - "settings.permissions.tool.codesearch.title": "코드 검색", - "settings.permissions.tool.codesearch.description": "웹에서 코드 검색", "settings.permissions.tool.external_directory.title": "외부 디렉터리", "settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스", "settings.permissions.tool.doom_loop.title": "무한 반복", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index d14dd6f98ba8..3014e1e5d304 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -807,8 +807,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Hent innhold fra en URL", "settings.permissions.tool.websearch.title": "Websøk", "settings.permissions.tool.websearch.description": "Søk på nettet", - "settings.permissions.tool.codesearch.title": "Kodesøk", - "settings.permissions.tool.codesearch.description": "Søk etter kode på nettet", "settings.permissions.tool.external_directory.title": "Ekstern mappe", "settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 9859ea0ae68f..a555f09e3514 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -729,8 +729,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL", "settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci", "settings.permissions.tool.websearch.description": "Przeszukiwanie sieci", - "settings.permissions.tool.codesearch.title": "Wyszukiwanie kodu", - "settings.permissions.tool.codesearch.description": "Przeszukiwanie kodu w sieci", "settings.permissions.tool.external_directory.title": "Katalog zewnętrzny", "settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu", "settings.permissions.tool.doom_loop.title": "Zapętlenie", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 6e6ca320303a..d8a81357b643 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -808,8 +808,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Получение контента по URL", "settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.description": "Поиск в интернете", - "settings.permissions.tool.codesearch.title": "Code Search", - "settings.permissions.tool.codesearch.description": "Поиск кода в интернете", "settings.permissions.tool.external_directory.title": "Внешняя директория", "settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 84e5d3ff215b..b51b9a13c330 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -796,8 +796,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL", "settings.permissions.tool.websearch.title": "ค้นหาเว็บ", "settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ", - "settings.permissions.tool.codesearch.title": "ค้นหาโค้ด", - "settings.permissions.tool.codesearch.description": "ค้นหาโค้ดบนเว็บ", "settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก", "settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 06e233cb51b9..0284e908b6c3 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -816,8 +816,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir", "settings.permissions.tool.websearch.title": "Web Ara", "settings.permissions.tool.websearch.description": "Web'de ara", - "settings.permissions.tool.codesearch.title": "Kod Ara", - "settings.permissions.tool.codesearch.description": "Web'de kod ara", "settings.permissions.tool.external_directory.title": "Harici Dizin", "settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş", "settings.permissions.tool.doom_loop.title": "Sonsuz Döngü", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index fa83707e8dac..e9d0fa4715b6 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -793,8 +793,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "从 URL 获取内容", "settings.permissions.tool.websearch.title": "网页搜索", "settings.permissions.tool.websearch.description": "搜索网页", - "settings.permissions.tool.codesearch.title": "代码搜索", - "settings.permissions.tool.codesearch.description": "在网上搜索代码", "settings.permissions.tool.external_directory.title": "外部目录", "settings.permissions.tool.external_directory.description": "访问项目目录之外的文件", "settings.permissions.tool.doom_loop.title": "死循环", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index e9d265acc03d..ff6392d25981 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -789,8 +789,6 @@ export const dict = { "settings.permissions.tool.webfetch.description": "從 URL 取得內容", "settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.description": "搜尋網頁", - "settings.permissions.tool.codesearch.title": "Code Search", - "settings.permissions.tool.codesearch.description": "在網路上搜尋程式碼", "settings.permissions.tool.external_directory.title": "外部目錄", "settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案", "settings.permissions.tool.doom_loop.title": "Doom Loop", diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 0319df4a0ed9..c4f9769224af 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -286,7 +286,6 @@ emitted JSON Schema must stay byte-identical. - [x] `src/tool/apply_patch.ts` - [x] `src/tool/bash.ts` -- [x] `src/tool/codesearch.ts` - [x] `src/tool/edit.ts` - [x] `src/tool/glob.ts` - [x] `src/tool/grep.ts` diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index 3cc277357bef..37a76e948794 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -40,7 +40,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`: - [x] `apply_patch.ts` - [x] `bash.ts` -- [x] `codesearch.ts` - [x] `edit.ts` - [x] `glob.ts` - [x] `grep.ts` @@ -79,7 +78,6 @@ Notable items that are already effectively on the target path and do not need se - `apply_patch.ts` - `grep.ts` - `write.ts` -- `codesearch.ts` - `websearch.ts` - `edit.ts` diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 81dbded08202..2a090b0eedef 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -169,7 +169,6 @@ export const layer = Layer.effect( bash: "allow", webfetch: "allow", websearch: "allow", - codesearch: "allow", read: "allow", external_directory: { "*": "ask", diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 80f91e7af31d..a1a440eaa1ac 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -28,7 +28,6 @@ const AVAILABLE_PERMISSIONS = [ "task", "todowrite", "websearch", - "codesearch", "lsp", "skill", ] diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 4c60023b7654..c94e9620386d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -19,7 +19,6 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { CodeSearchTool } from "../../tool/codesearch" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" @@ -145,13 +144,6 @@ function edit(info: ToolProps) { ) } -function codesearch(info: ToolProps) { - inline({ - icon: "◇", - title: `Exa Code Search "${info.input.query}"`, - }) -} - function websearch(info: ToolProps) { inline({ icon: "◈", @@ -415,7 +407,6 @@ export const RunCommand = cmd({ 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 === "codesearch") return codesearch(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)) 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 183ce52cde4e..60343de4966a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -44,7 +44,6 @@ import type { GrepTool } from "@/tool/grep" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" -import type { CodeSearchTool } from "@/tool/codesearch" import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -1565,9 +1564,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - - @@ -1948,15 +1944,6 @@ function WebFetch(props: ToolProps) { ) } -function CodeSearch(props: ToolProps) { - const metadata = props.metadata as { results?: number } - return ( - - Exa Code Search "{props.input.query}" ({metadata.results} results) - - ) -} - function WebSearch(props: ToolProps) { const metadata = props.metadata as { numResults?: number } return ( 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 30e2358ad2ca..720a05ff7e16 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -350,21 +350,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "codesearch") { - const query = typeof data.query === "string" ? data.query : "" - return { - icon: "◇", - title: `Exa Code Search "${query}"`, - body: ( - - - {"Query: " + query} - - - ), - } - } - if (permission === "external_directory") { const meta = props.request.metadata ?? {} const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 29278338dc26..9513951c29ed 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -35,7 +35,6 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), - codesearch: Schema.optional(Action), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt index 87cab043bdd2..d8da6d2017b1 100644 --- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt +++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt @@ -91,7 +91,7 @@ Remember that you MUST add links for all workspace files, for example: [path/to/ These instructions only apply when the question is about the user's workspace. -Unless it is clear that the user's question relates to the current workspace, you should avoid using the code search tools and instead prefer to answer the user's question directly. +Unless it is clear that the user's question relates to the current workspace, you should avoid using workspace search tools and instead prefer to answer the user's question directly. Remember that you can call multiple tools in one response. Use semantic_search to search for high level concepts or descriptions of functionality in the user's question. This is the best place to start if you don't know where to look or the exact strings found in the codebase. Prefer search_workspace_symbols over grep_search when you have precise code identifiers to search for. diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts deleted file mode 100644 index 2753732dd0ae..000000000000 --- a/packages/opencode/src/tool/codesearch.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, Schema } from "effect" -import { HttpClient } from "effect/unstable/http" -import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" -import DESCRIPTION from "./codesearch.txt" - -export const Parameters = Schema.Struct({ - query: Schema.String.annotate({ - description: - "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - }), - tokensNum: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)) - .check(Schema.isLessThanOrEqualTo(50000)) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) - .annotate({ - description: - "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - }), -}) - -export const CodeSearchTool = Tool.define( - "codesearch", - Effect.gen(function* () { - const http = yield* HttpClient.HttpClient - - return { - description: DESCRIPTION, - parameters: Parameters, - execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => - Effect.gen(function* () { - yield* ctx.ask({ - permission: "codesearch", - patterns: [params.query], - always: ["*"], - metadata: { - query: params.query, - tokensNum: params.tokensNum, - }, - }) - - const result = yield* McpExa.call( - http, - "get_code_context_exa", - McpExa.CodeArgs, - { - query: params.query, - tokensNum: params.tokensNum, - }, - "30 seconds", - ) - - return { - output: - result ?? - "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", - title: `Code search: ${params.query}`, - metadata: {}, - } - }).pipe(Effect.orDie), - } - }), -) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt deleted file mode 100644 index 4187f08d12ad..000000000000 --- a/packages/opencode/src/tool/codesearch.txt +++ /dev/null @@ -1,12 +0,0 @@ -- Search and get relevant context for any programming task using Exa Code API -- Provides the highest quality and freshest context for libraries, SDKs, and APIs -- Use this tool for ANY question or task related to programming -- Returns comprehensive code examples, documentation, and API references -- Optimized for finding specific programming patterns and solutions - -Usage notes: - - Adjustable token count (1000-50000) for focused or comprehensive results - - Default 5000 tokens provides balanced context for most queries - - Use lower values for specific questions, higher values for comprehensive documentation - - Supports queries about frameworks, libraries, APIs, and programming concepts - - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-exa.ts index 3340d84efd07..af9a3390e3c6 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-exa.ts @@ -35,11 +35,6 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) -export const CodeArgs = Schema.Struct({ - query: Schema.String, - tokensNum: Schema.Number, -}) - const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4b442d4e3a00..a9a853e504eb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,7 +22,6 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" -import { CodeSearchTool } from "./codesearch" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { LspTool } from "./lsp" @@ -108,7 +107,6 @@ export const layer: Layer.Layer< const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool const bash = yield* BashTool - const codesearch = yield* CodeSearchTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -198,7 +196,6 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), - code: Tool.init(codesearch), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -221,7 +218,6 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, - tool.code, tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), @@ -278,7 +274,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { - if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { + if (tool.id === WebSearchTool.id) { return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA } diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 02de54406a4c..601f07cb3a55 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -60,29 +60,6 @@ Output: Creates directory 'foo'" } `; -exports[`tool parameters JSON Schema (wire shape) codesearch 1`] = ` -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "query": { - "description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", - "type": "string", - }, - "tokensNum": { - "default": 5000, - "description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", - "maximum": 50000, - "minimum": 1000, - "type": "integer", - }, - }, - "required": [ - "query", - ], - "type": "object", -} -`; - exports[`tool parameters JSON Schema (wire shape) edit 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 8ea008a457b4..bc42b0324b8b 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -11,7 +11,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" import { Parameters as Bash } from "../../src/tool/bash" -import { Parameters as CodeSearch } from "../../src/tool/codesearch" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -37,7 +36,6 @@ describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) - test("codesearch", () => expect(toJsonSchema(CodeSearch)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -85,21 +83,6 @@ describe("tool parameters", () => { }) }) - describe("codesearch", () => { - test("accepts query; tokensNum defaults to 5000", () => { - expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 }) - }) - test("accepts override tokensNum", () => { - expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000) - }) - test("rejects tokensNum under 1000", () => { - expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false) - }) - test("rejects tokensNum over 50000", () => { - expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).toBe(false) - }) - }) - describe("edit", () => { test("accepts all four fields", () => { expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b6ab684678e9..d98d5c6fe18e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1221,7 +1221,6 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig - codesearch?: PermissionActionConfig lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bb6c74b838d1..65c1d810c580 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11231,9 +11231,6 @@ "websearch": { "$ref": "#/components/schemas/PermissionActionConfig" }, - "codesearch": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, "lsp": { "$ref": "#/components/schemas/PermissionRuleConfig" }, diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 013272205085..cc046fdfc577 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -356,12 +356,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { title: i18n.t("ui.tool.websearch"), subtitle: input.query, } - case "codesearch": - return { - icon: "code", - title: i18n.t("ui.tool.codesearch"), - subtitle: input.query, - } case "task": { const type = typeof input.subagent_type === "string" && input.subagent_type @@ -1710,32 +1704,6 @@ ToolRegistry.register({ }, }) -ToolRegistry.register({ - name: "codesearch", - render(props) { - const i18n = useI18n() - const query = createMemo(() => { - const value = props.input.query - if (typeof value !== "string") return "" - return value - }) - - return ( - - - - ) - }, -}) - ToolRegistry.register({ name: "task", render(props) { diff --git a/packages/ui/src/components/tool-error-card.stories.tsx b/packages/ui/src/components/tool-error-card.stories.tsx index 03349ce011c7..dd600758122e 100644 --- a/packages/ui/src/components/tool-error-card.stories.tsx +++ b/packages/ui/src/components/tool-error-card.stories.tsx @@ -43,10 +43,6 @@ const samples = [ tool: "websearch", error: "websearch Rate limited: Please try again in 30 seconds", }, - { - tool: "codesearch", - error: "codesearch Timeout: exceeded 120s", - }, { tool: "question", error: "question Dismissed: user dismissed this question", @@ -72,7 +68,7 @@ export default { argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "codesearch", "question"], + options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], }, error: { control: "text", diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index 9983e2fe79b9..4f0df6cb4d7f 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -33,7 +33,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { task: "ui.tool.task", webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", - codesearch: "ui.tool.codesearch", bash: "ui.tool.shell", apply_patch: "ui.tool.patch", question: "ui.tool.questions", diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index ea4d03ac67ac..eaed41194915 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -95,7 +95,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "جلب الويب", "ui.tool.websearch": "بحث الويب", - "ui.tool.codesearch": "بحث الكود", "ui.tool.shell": "Shell", "ui.tool.patch": "تصحيح", "ui.tool.todos": "المهام", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index e8aefd937815..2fd24f37fa4b 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -95,7 +95,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Buscar Web", "ui.tool.websearch": "Pesquisa na Web", - "ui.tool.codesearch": "Pesquisa de Código", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Tarefas", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 4e8ce8042561..3dc21b837da6 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -99,7 +99,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Web preuzimanje", "ui.tool.websearch": "Pretraga weba", - "ui.tool.codesearch": "Pretraga koda", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Lista zadataka", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 846fb3ecaa27..1d4f848c195b 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -94,7 +94,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webhentning", "ui.tool.websearch": "Websøgning", - "ui.tool.codesearch": "Kodesøgning", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Opgaver", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index c07ceade2f62..dd841cd000b1 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -100,7 +100,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webabruf", "ui.tool.websearch": "Websuche", - "ui.tool.codesearch": "Codesuche", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Aufgaben", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 837fd5afcbcd..d0d25bad9f42 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -108,7 +108,6 @@ export const dict: Record = { "ui.tool.task": "Task", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Web Search", - "ui.tool.codesearch": "Code Search", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "To-dos", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 3f4475a39bd2..83d48b300d38 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -95,7 +95,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Búsqueda web", - "ui.tool.codesearch": "Búsqueda de código", "ui.tool.shell": "Shell", "ui.tool.patch": "Parche", "ui.tool.todos": "Tareas", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 9cf17d5e336b..9a4da7e75d97 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -95,7 +95,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Recherche Web", - "ui.tool.codesearch": "Recherche de code", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Tâches", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 5bb7f1376bd3..b6501c13e1fc 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -94,7 +94,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Web検索", - "ui.tool.codesearch": "コード検索", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Todo", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 088a2865af4a..ba1c25826484 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -95,7 +95,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "웹 가져오기", "ui.tool.websearch": "웹 검색", - "ui.tool.codesearch": "코드 검색", "ui.tool.shell": "셸", "ui.tool.patch": "패치", "ui.tool.todos": "할 일", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index af6e399eac5f..91c424e74d66 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -98,7 +98,6 @@ export const dict: Record = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webhenting", "ui.tool.websearch": "Nettsøk", - "ui.tool.codesearch": "Kodesøk", "ui.tool.shell": "Shell", "ui.tool.patch": "Patch", "ui.tool.todos": "Gjøremål", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 2385edfeb751..aeba784e54e6 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -94,7 +94,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Pobieranie sieciowe", "ui.tool.websearch": "Wyszukiwanie w sieci", - "ui.tool.codesearch": "Wyszukiwanie kodu", "ui.tool.shell": "Terminal", "ui.tool.patch": "Patch", "ui.tool.todos": "Zadania", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index f78bd5c14b60..c97c76dd76ec 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -94,7 +94,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Веб-поиск", - "ui.tool.codesearch": "Поиск кода", "ui.tool.shell": "Оболочка", "ui.tool.patch": "Патч", "ui.tool.todos": "Задачи", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index a49fc2e52e28..775a583027c0 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -96,7 +96,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "ดึงจากเว็บ", "ui.tool.websearch": "ค้นหาเว็บ", - "ui.tool.codesearch": "ค้นหาโค้ด", "ui.tool.shell": "เชลล์", "ui.tool.patch": "แพตช์", "ui.tool.todos": "รายการงาน", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index e6b50c1c09a3..faad574d85b1 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -101,7 +101,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Web getir", "ui.tool.websearch": "Web Araması", - "ui.tool.codesearch": "Kod Araması", "ui.tool.shell": "Kabuk", "ui.tool.patch": "Yama", "ui.tool.todos": "Görevler", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 5779754376b9..b31b18e3d350 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -99,7 +99,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "网络搜索", - "ui.tool.codesearch": "代码搜索", "ui.tool.shell": "Shell", "ui.tool.patch": "补丁", "ui.tool.todos": "待办", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 2d433b57ab41..75add8ce3338 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -99,7 +99,6 @@ export const dict = { "ui.tool.grep": "Grep", "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "網頁搜尋", - "ui.tool.codesearch": "程式碼搜尋", "ui.tool.shell": "Shell", "ui.tool.patch": "修補", "ui.tool.todos": "待辦", diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 7c5f21787828..d7c85bc5172c 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -434,7 +434,6 @@ The available permission keys are: | `todowrite` | `todowrite`, `todoread` | | `webfetch` | `webfetch` | | `websearch` | `websearch` | -| `codesearch` | `codesearch` | | `lsp` | `lsp` | | `skill` | `skill` | | `question` | `question` | diff --git a/packages/web/src/content/docs/ar/permissions.mdx b/packages/web/src/content/docs/ar/permissions.mdx index 2f4d1c47c557..c50428cb77ad 100644 --- a/packages/web/src/content/docs/ar/permissions.mdx +++ b/packages/web/src/content/docs/ar/permissions.mdx @@ -138,7 +138,7 @@ description: تحكّم في الإجراءات التي تتطلب موافقة - `skill` — تحميل مهارة (يطابق اسم المهارة) - `lsp` — تشغيل استعلامات LSP (حاليًا دون قواعد دقيقة) - `webfetch` — جلب عنوان URL (يطابق الـ URL) -- `websearch`, `codesearch` — بحث الويب/الكود (يطابق الاستعلام) +- `websearch` — بحث الويب (يطابق الاستعلام) - `external_directory` — يُفعَّل عندما تلمس أداة مسارات خارج دليل عمل المشروع - `doom_loop` — يُفعَّل عندما يتكرر نفس استدعاء الأداة 3 مرات مع نفس المُدخلات diff --git a/packages/web/src/content/docs/bs/permissions.mdx b/packages/web/src/content/docs/bs/permissions.mdx index ea8c3f00f35e..4192694fe3ff 100644 --- a/packages/web/src/content/docs/bs/permissions.mdx +++ b/packages/web/src/content/docs/bs/permissions.mdx @@ -133,7 +133,7 @@ Dozvole OpenCode su označene imenom alata, plus nekoliko sigurnosnih mjera: - `skill` — učitavanje vještine (odgovara nazivu vještine) - `lsp` — pokretanje LSP upita (trenutno negranularno) - `webfetch` — dohvaćanje URL-a (odgovara URL-u) -- `websearch`, `codesearch` — pretraživanje weba/koda (odgovara upitu) +- `websearch` — pretraživanje weba (odgovara upitu) - `external_directory` — pokreće se kada alat dodirne staze izvan radnog direktorija projekta - `doom_loop` — aktivira se kada se isti poziv alata ponovi 3 puta sa identičnim unosom diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 0df6b376e175..fa6a77bc219a 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -102,7 +102,7 @@ This command will guide you through creating a new agent with a custom system pr | `--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`, `codesearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | +| `--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. diff --git a/packages/web/src/content/docs/da/permissions.mdx b/packages/web/src/content/docs/da/permissions.mdx index 30de7aa8633b..2594f4d243f2 100644 --- a/packages/web/src/content/docs/da/permissions.mdx +++ b/packages/web/src/content/docs/da/permissions.mdx @@ -138,7 +138,7 @@ OpenCode tilladelser indtastes efter værktøjsnavn plus et par sikkerhedsafskæ - `skill` — indlæsning af en færdighed (matcher færdighedsnavnet) - `lsp` — kører LSP forespørgsler (i øjeblikket ikke-granulære) - `webfetch` — henter en URL (matcher URL) -- `websearch`, `codesearch` — web/code søgning (matcher forespørgslen) +- `websearch` — websøgning (matcher forespørgslen) - `external_directory` — udløses, når et værktøj berører stier uden for projektets arbejdsmappe - `doom_loop` — udløses, når det samme værktøjskald gentages 3 gange med identisk input diff --git a/packages/web/src/content/docs/de/permissions.mdx b/packages/web/src/content/docs/de/permissions.mdx index 6769ae74d36c..de51c20c0961 100644 --- a/packages/web/src/content/docs/de/permissions.mdx +++ b/packages/web/src/content/docs/de/permissions.mdx @@ -138,7 +138,7 @@ OpenCode-Berechtigungen basieren auf Tool-Namen sowie einigen Sicherheitsvorkehr - `skill` – Laden einer Fertigkeit (entspricht dem Fertigkeitsnamen) - `lsp` – Ausführen von LSP-Abfragen (derzeit nicht granular) - `webfetch` – Abrufen eines URL (entspricht dem URL) -- `websearch`, `codesearch` – web/code Suche (entspricht der Abfrage) +- `websearch` – Websuche (entspricht der Abfrage) - `external_directory` – wird ausgelöst, wenn ein Tool Pfade außerhalb des Projektarbeitsverzeichnisses berührt - `doom_loop` – wird ausgelöst, wenn derselbe Werkzeugaufruf dreimal mit identischer Eingabe wiederholt wird diff --git a/packages/web/src/content/docs/es/permissions.mdx b/packages/web/src/content/docs/es/permissions.mdx index 131ab323b41b..af6e5f3e4476 100644 --- a/packages/web/src/content/docs/es/permissions.mdx +++ b/packages/web/src/content/docs/es/permissions.mdx @@ -138,7 +138,7 @@ Los permisos OpenCode están codificados por el nombre de la herramienta, ademá - `skill` — cargar una habilidad (coincide con el nombre de la habilidad) - `lsp`: ejecución de consultas LSP (actualmente no granulares) - `webfetch` — obteniendo una URL (coincide con la URL) -- `websearch`, `codesearch` — búsqueda web/código (coincide con la consulta) +- `websearch` — búsqueda web (coincide con la consulta) - `external_directory`: se activa cuando una herramienta toca rutas fuera del directorio de trabajo del proyecto. - `doom_loop`: se activa cuando la misma llamada de herramienta se repite 3 veces con entrada idéntica diff --git a/packages/web/src/content/docs/fr/permissions.mdx b/packages/web/src/content/docs/fr/permissions.mdx index b3b9d7e2d139..99902647afa3 100644 --- a/packages/web/src/content/docs/fr/permissions.mdx +++ b/packages/web/src/content/docs/fr/permissions.mdx @@ -138,7 +138,7 @@ Les autorisations OpenCode sont classées par nom d'outil, plus quelques garde-f - `skill` — chargement d'une compétence (correspond au nom de la compétence) - `lsp` — exécution de requêtes LSP (actuellement non granulaires) - `webfetch` — récupérer une URL (correspond à l'URL) -- `websearch`, `codesearch` — recherche Web/code (correspond à la requête) +- `websearch` — recherche Web (correspond à la requête) - `external_directory` - déclenché lorsqu'un outil touche des chemins en dehors du répertoire de travail du projet - `doom_loop` — déclenché lorsque le même appel d'outil se répète 3 fois avec une entrée identique diff --git a/packages/web/src/content/docs/it/permissions.mdx b/packages/web/src/content/docs/it/permissions.mdx index 417bf4a239a2..d4c848abae26 100644 --- a/packages/web/src/content/docs/it/permissions.mdx +++ b/packages/web/src/content/docs/it/permissions.mdx @@ -138,7 +138,7 @@ I permessi di OpenCode sono indicizzati per nome dello strumento, piu' un paio d - `skill` — caricamento di una skill (corrisponde al nome della skill) - `lsp` — esecuzione query LSP (attualmente non granulare) - `webfetch` — fetch di un URL (corrisponde all'URL) -- `websearch`, `codesearch` — ricerca web/codice (corrisponde alla query) +- `websearch` — ricerca web (corrisponde alla query) - `external_directory` — si attiva quando uno strumento tocca percorsi fuori dalla working directory del progetto - `doom_loop` — si attiva quando la stessa chiamata a uno strumento si ripete 3 volte con input identico diff --git a/packages/web/src/content/docs/ja/permissions.mdx b/packages/web/src/content/docs/ja/permissions.mdx index 55f6f870078a..6b8c06a0a998 100644 --- a/packages/web/src/content/docs/ja/permissions.mdx +++ b/packages/web/src/content/docs/ja/permissions.mdx @@ -138,7 +138,7 @@ OpenCode の権限は、ツール名に加えて、いくつかの安全対策 - `skill` — スキルをロードしています(スキル名と一致します) - `lsp` — LSP クエリの実行 (現在は非細分性) - `webfetch` — URL を取得します (URL と一致します) -- `websearch`、`codesearch` — Web/コード検索 (クエリと一致) +- `websearch` — Web検索 (クエリと一致) - `external_directory` — ツールがプロジェクトの作業ディレクトリ外のパスにアクセスするとトリガーされます。 - `doom_loop` — 同じ入力で同じツール呼び出しが 3 回繰り返されたときにトリガーされます。 diff --git a/packages/web/src/content/docs/ko/permissions.mdx b/packages/web/src/content/docs/ko/permissions.mdx index e19c3c49c35d..c956b8f11293 100644 --- a/packages/web/src/content/docs/ko/permissions.mdx +++ b/packages/web/src/content/docs/ko/permissions.mdx @@ -138,7 +138,7 @@ opencode 권한은 도구 이름에 의해 키 입력되며, 두 개의 안전 - `skill` - 기술을 로딩 (기술 이름을 매칭) - `lsp` - LSP 쿼리 실행 (현재 비 과립) - `webfetch` - URL을 fetching ( URL을 매칭) -- `websearch`, `codesearch` - 웹 / 코드 검색 (문자 쿼리) +- `websearch` - 웹 검색 (문자 쿼리) - `external_directory` - 프로젝트 작업 디렉토리 외부의 도구 접촉 경로 때 트리거 - `doom_loop` - 동일한 도구 호출이 동일한 입력으로 3 번 반복 할 때 트리거 diff --git a/packages/web/src/content/docs/nb/permissions.mdx b/packages/web/src/content/docs/nb/permissions.mdx index 5f54d23aa006..09d72442e327 100644 --- a/packages/web/src/content/docs/nb/permissions.mdx +++ b/packages/web/src/content/docs/nb/permissions.mdx @@ -138,7 +138,7 @@ OpenCode-tillatelser tastes inn etter verktøynavn, pluss et par sikkerhetsvakte - `skill` — laster en ferdighet (tilsvarer navnet på ferdigheten) - `lsp` — kjører LSP-spørringer (for øyeblikket ikke-granulære) - `webfetch` — henter en URL (tilsvarer URL) -- `websearch`, `codesearch` - nett-/kodesøk (samsvarer med søket) +- `websearch` - nettsøk (samsvarer med søket) - `external_directory` - utløses når et verktøy berører stier utenfor prosjektets arbeidskatalog - `doom_loop` — utløses når det samme verktøykallet gjentas 3 ganger med identisk inndata diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index f2efe3a49bb3..19182e27491b 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -139,7 +139,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `lsp` — running LSP queries (currently non-granular) - `question` — asking the user questions during execution - `webfetch` — fetching a URL (matches the URL) -- `websearch`, `codesearch` — web/code search (matches the query) +- `websearch` — web search (matches the query) - `external_directory` — triggered when a tool touches paths outside the project working directory - `doom_loop` — triggered when the same tool call repeats 3 times with identical input diff --git a/packages/web/src/content/docs/pl/permissions.mdx b/packages/web/src/content/docs/pl/permissions.mdx index b17b873472e8..c33857277d2b 100644 --- a/packages/web/src/content/docs/pl/permissions.mdx +++ b/packages/web/src/content/docs/pl/permissions.mdx @@ -138,7 +138,7 @@ Uprawnienia opencode są określane na podstawie nazwy narzędzia i kilku zabezp - `skill` — ładowanie umiejętności (pasuje do nazwy umiejętności) - `lsp` — uruchamianie zapytań LSP (obecnie nieszczegółowych) - `webfetch` — pobieranie adresu URL (pasuje do adresu URL) -- `websearch`, `codesearch` — wyszukiwanie sieci/kodu (pasuje do zapytań) +- `websearch` — wyszukiwanie sieci (pasuje do zapytań) - `external_directory` — wywoływacz, gdy narzędzie jest dostępne poza katalogiem roboczym projektu - `doom_loop` — wyzwalane, gdy samo wywołanie narzędzia zostanie powtórzone 3 razy z tymi samymi danymi podstawowymi diff --git a/packages/web/src/content/docs/pt-br/permissions.mdx b/packages/web/src/content/docs/pt-br/permissions.mdx index 077524b87fdb..a0d5432c5599 100644 --- a/packages/web/src/content/docs/pt-br/permissions.mdx +++ b/packages/web/src/content/docs/pt-br/permissions.mdx @@ -138,7 +138,7 @@ As permissões do opencode são indexadas pelo nome da ferramenta, além de algu - `skill` — carregamento de uma habilidade (corresponde ao nome da habilidade) - `lsp` — execução de consultas LSP (atualmente não granular) - `webfetch` — busca de uma URL (corresponde à URL) -- `websearch`, `codesearch` — busca na web/código (corresponde à consulta) +- `websearch` — busca na web (corresponde à consulta) - `external_directory` — acionado quando uma ferramenta toca em caminhos fora do diretório de trabalho do projeto - `doom_loop` — acionado quando a mesma chamada de ferramenta se repete 3 vezes com entrada idêntica diff --git a/packages/web/src/content/docs/ru/permissions.mdx b/packages/web/src/content/docs/ru/permissions.mdx index 7e027029e688..05028c8bb3d6 100644 --- a/packages/web/src/content/docs/ru/permissions.mdx +++ b/packages/web/src/content/docs/ru/permissions.mdx @@ -138,7 +138,7 @@ opencode использует конфигурацию `permission`, чтобы - `skill` — загрузка навыка (соответствует названию навыка) - `lsp` — выполнение запросов LSP (в настоящее время не детализированных) - `webfetch` — получение URL-адреса (соответствует URL-адресу) -- `websearch`, `codesearch` — поиск в сети/коде (соответствует запросу) +- `websearch` — поиск в сети (соответствует запросу) - `external_directory` — срабатывает, когда инструмент касается путей за пределами рабочего каталога проекта. - `doom_loop` — срабатывает, когда один и тот же вызов инструмента повторяется 3 раза с одинаковым вводом. diff --git a/packages/web/src/content/docs/th/permissions.mdx b/packages/web/src/content/docs/th/permissions.mdx index 4526b7495d79..55457ec56358 100644 --- a/packages/web/src/content/docs/th/permissions.mdx +++ b/packages/web/src/content/docs/th/permissions.mdx @@ -138,7 +138,7 @@ OpenCode ใช้การกำหนดค่า `permission` เพื่อ - `skill` — กำลังโหลดทักษะ (ตรงกับชื่อทักษะ) - `lsp` — กำลังเรียกใช้คำสั่ง LSP (ปัจจุบันยังไม่ละเอียด) - `webfetch` — กำลังดึง URL (ตรงกับ URL) -- `websearch`, `codesearch` — การค้นหาเว็บ/code (ตรงกับข้อความค้นหา) +- `websearch` — การค้นหาเว็บ (ตรงกับข้อความค้นหา) - `external_directory` — ทริกเกอร์เมื่อเครื่องมือแตะเส้นทางนอกไดเร็กทอรีการทำงานของโปรเจ็กต์ - `doom_loop` — ทริกเกอร์เมื่อมีการเรียกใช้เครื่องมือเดียวกันซ้ำ 3 ครั้งโดยมีอินพุตเหมือนกัน diff --git a/packages/web/src/content/docs/tr/permissions.mdx b/packages/web/src/content/docs/tr/permissions.mdx index 89c0ebb2c585..67dded220927 100644 --- a/packages/web/src/content/docs/tr/permissions.mdx +++ b/packages/web/src/content/docs/tr/permissions.mdx @@ -138,7 +138,7 @@ opencode izinleri araç adına ve birkaç güvenlik önlemine göre anahtarlanı - `skill` — bir skill yükleniyor (skill adıyla eşleşir) - `lsp` — LSP sorgularını çalıştırıyor (şu anda ayrıntılı değil) - `webfetch` — URL getiriliyor (URL ile eşleşiyor) -- `websearch`, `codesearch` — web/kod arama (sorguyla eşleşir) +- `websearch` — web arama (sorguyla eşleşir) - `external_directory` — bir araç proje çalışma dizini dışındaki yollara dokunduğunda tetiklenir - `doom_loop` — aynı araç çağrısı aynı girdiyle 3 kez tekrarlandığında tetiklenir diff --git a/packages/web/src/content/docs/zh-cn/permissions.mdx b/packages/web/src/content/docs/zh-cn/permissions.mdx index a497742c0980..7f905ac22e97 100644 --- a/packages/web/src/content/docs/zh-cn/permissions.mdx +++ b/packages/web/src/content/docs/zh-cn/permissions.mdx @@ -138,7 +138,7 @@ OpenCode 的权限以工具名称为键,外加几个安全防护项: - `skill` — 加载技能(匹配技能名称) - `lsp` — 运行 LSP 查询(当前不支持细粒度配置) - `webfetch` — 获取 URL(匹配 URL) -- `websearch`、`codesearch` — 网页/代码搜索(匹配查询内容) +- `websearch` — 网页搜索(匹配查询内容) - `external_directory` — 当工具访问项目工作目录之外的路径时触发 - `doom_loop` — 当同一工具调用以相同输入重复 3 次时触发 diff --git a/packages/web/src/content/docs/zh-tw/permissions.mdx b/packages/web/src/content/docs/zh-tw/permissions.mdx index 5d98bddfb22b..08670da80e0c 100644 --- a/packages/web/src/content/docs/zh-tw/permissions.mdx +++ b/packages/web/src/content/docs/zh-tw/permissions.mdx @@ -138,7 +138,7 @@ OpenCode 的權限以工具名稱為鍵,外加幾個安全防護項: - `skill` — 載入技能(比對技能名稱) - `lsp` — 執行 LSP 查詢(目前不支援細粒度設定) - `webfetch` — 擷取 URL(比對 URL) -- `websearch`、`codesearch` — 網頁/程式碼搜尋(比對查詢內容) +- `websearch` — 網頁搜尋(比對查詢內容) - `external_directory` — 當工具存取專案工作目錄之外的路徑時觸發 - `doom_loop` — 當同一工具呼叫以相同輸入重複 3 次時觸發 From c48000655458bf1317314413259808b8f8293dd0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Apr 2026 18:17:10 +0000 Subject: [PATCH 0008/1114] chore: generate --- packages/web/src/content/docs/cli.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index fa6a77bc219a..7249f4dc9003 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -97,13 +97,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` | +| 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 | +| `--model`, `-m` | Model to use, in `provider/model` format | Passing all of `--path`, `--description`, `--mode`, and `--permissions` runs the command non-interactively. From 293877cb7e60610b4b0c25992dbab2169c6f614e Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 29 Apr 2026 15:11:44 -0400 Subject: [PATCH 0009/1114] fix(core): reconnect editor context for session directory (#24984) --- .../cli/cmd/tui/component/prompt/index.tsx | 75 +++-- .../src/cli/cmd/tui/context/editor.ts | 295 ++++++++++-------- .../src/cli/cmd/tui/routes/session/index.tsx | 3 + ...ext.test.ts => editor-context-zed.test.ts} | 0 .../test/cli/tui/editor-context.test.tsx | 224 +++++++++++++ packages/opencode/test/lib/websocket.ts | 46 +++ 6 files changed, 479 insertions(+), 164 deletions(-) rename packages/opencode/test/cli/tui/{editor-context.test.ts => editor-context-zed.test.ts} (100%) create mode 100644 packages/opencode/test/cli/tui/editor-context.test.tsx create mode 100644 packages/opencode/test/lib/websocket.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 641edd30a303..cd47e917085b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" -import { useEditorContext } from "@tui/context/editor" +import { useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } +function getEditorSelectionKey(selection: EditorSelection) { + return [ + selection.filePath, + selection.text, + selection.source ?? "", + selection.selection.start.line, + selection.selection.start.character, + selection.selection.end.line, + selection.selection.end.character, + ].join("-") +} + let stashed: { prompt: PromptInfo; cursor: number } | undefined export function Prompt(props: PromptProps) { @@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) + let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) @@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() const editorSelection = fileContextEnabled() ? editor.selection() : undefined - const editorParts = editorSelection - ? [ - { - id: PartID.ascending(), - type: "text" as const, - text: (() => { - const start = editorSelection.selection.start - const end = editorSelection.selection.end - - let text = "" - if (start.line === end.line && start.character === end.character) { - text = `Note: The user opened the file "${editorSelection.filePath}".` - } else if (start.line === end.line) { - text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } else { - text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } + const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined + const editorParts = + editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey + ? [ + { + id: PartID.ascending(), + type: "text" as const, + text: (() => { + const start = editorSelection.selection.start + const end = editorSelection.selection.end + + let text = "" + if (start.line === end.line && start.character === end.character) { + text = `Note: The user opened the file "${editorSelection.filePath}".` + } else if (start.line === end.line) { + text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` + } else { + text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` + } - return `${text} This may or may not be relevant to the current task.\n` - })(), - synthetic: true, - metadata: { - kind: "editor_context", - source: editorSelection.source ?? "editor", - filePath: editorSelection.filePath, - selection: editorSelection.selection, + return `${text} This may or may not be relevant to the current task.\n` + })(), + synthetic: true, + metadata: { + kind: "editor_context", + source: editorSelection.source ?? "editor", + filePath: editorSelection.filePath, + selection: editorSelection.selection, + }, }, - }, - ] - : [] + ] + : [] if (store.mode === "shell") { void sdk.client.session.shell({ @@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - editor.clearSelection() + lastSubmittedEditorSelectionKey = editorSelectionKey } history.append({ ...store.prompt, diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 531bf4507d20..4ebc1c2c06c0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -75,8 +75,9 @@ type EditorLockFile = { export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ name: "EditorContext", - init: () => { + init: (props: { WebSocketImpl?: typeof WebSocket }) => { const mentionListeners = new Set<(mention: EditorMention) => void>() + const WebSocketImpl = props.WebSocketImpl ?? WebSocket const [store, setStore] = createStore<{ status: "disabled" | "connecting" | "connected" selection: EditorSelection | undefined @@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create server: undefined, }) - onMount(() => { - let socket: WebSocket | undefined - let closed = false - let reconnect: ReturnType | undefined - let attempt = 0 - let requestID = 0 - let zedSelection: Promise | undefined - let lastZedSelectionKey: string | undefined - const pending = new Map() - - const send = (payload: JsonRpcMessage) => { - if (!socket || socket.readyState !== WebSocket.OPEN) return - socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) - } + let socket: WebSocket | undefined + let closed = false + let reconnect: ReturnType | undefined + let attempt = 0 + let requestID = 0 + let zedSelection: Promise | undefined + let lastZedSelectionKey: string | undefined + let directory = process.cwd() + const pending = new Map() + + const send = (payload: JsonRpcMessage) => { + if (!socket || socket.readyState !== 1) return + socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload })) + } - const request = (method: string, params?: unknown) => { - requestID += 1 - pending.set(requestID, method) - send({ id: requestID, method, params }) - } + const request = (method: string, params?: unknown) => { + requestID += 1 + pending.set(requestID, method) + send({ id: requestID, method, params }) + } - const scheduleReconnect = () => { - if (closed) return - if (reconnect) clearTimeout(reconnect) - attempt += 1 - const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) - reconnect = setTimeout(connect, delay) - } + const connect = () => { + if (closed) return - const scheduleZedPoll = () => { - if (closed) return - if (reconnect) clearTimeout(reconnect) - reconnect = setTimeout(connect, 1000) + const connection = resolveEditorConnection(directory) + if (!connection) { + const dbPath = resolveZedDbPath() + if (!dbPath) { + setStore("status", "disabled") + scheduleReconnect() + return + } + zedSelection ??= resolveZedSelection(dbPath, directory) + .then((result) => { + if (closed || socket) return + if (result.type === "unavailable") return + const selection = result.type === "selection" ? result.selection : undefined + const key = editorSelectionKey(selection) + if (key !== lastZedSelectionKey) { + lastZedSelectionKey = key + setStore("selection", selection) + setStore("status", selection ? "connected" : "disabled") + } + }) + .catch(() => { + // Keep the last known Zed selection for transient polling failures. + }) + .finally(() => { + zedSelection = undefined + }) + scheduleZedPoll() + return } - const connect = () => { - if (closed) return + setStore("status", "connecting") + const current = openEditorSocket(connection, WebSocketImpl) + socket = current - const connection = resolveEditorConnection() - if (!connection) { - const dbPath = resolveZedDbPath() - if (!dbPath) { - setStore("status", "disabled") - scheduleReconnect() - return - } - zedSelection ??= resolveZedSelection(dbPath) - .then((result) => { - if (closed || socket) return - if (result.type === "unavailable") return - const selection = result.type === "selection" ? result.selection : undefined - const key = editorSelectionKey(selection) - if (key !== lastZedSelectionKey) { - lastZedSelectionKey = key - setStore("selection", selection) - setStore("status", selection ? "connected" : "disabled") - } - }) - .catch(() => { - // Keep the last known Zed selection for transient polling failures. - }) - .finally(() => { - zedSelection = undefined - }) - scheduleZedPoll() + current.addEventListener("open", () => { + if (socket !== current) { + current.close() return } - setStore("status", "connecting") - const current = openEditorSocket(connection) - socket = current - - current.addEventListener("open", () => { - if (socket !== current) { - current.close() - return - } - - attempt = 0 - setStore("status", "connected") - request("initialize", { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "opencode", version: "0.0.0" }, - }) + attempt = 0 + setStore("status", "connected") + request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode", version: "0.0.0" }, }) + }) - current.addEventListener("message", (event) => { - const message = parseMessage(event.data) - if (!message) return - - const selection = - message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined - if (selection?.success) { - setStore("selection", { ...selection.data, source: "websocket" }) - return - } - - const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined - if (mention?.success) { - mentionListeners.forEach((listener) => listener(mention.data)) - return - } - - if (typeof message.id !== "number") return - - const method = pending.get(message.id) - if (!method) return - - pending.delete(message.id) - if (message.error) return - - const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined - if (initialize?.success) { - setStore("server", initialize.data) - send({ method: "notifications/initialized" }) - return - } - }) + current.addEventListener("message", (event) => { + const message = parseMessage(event.data) + if (!message) return - current.addEventListener("close", () => { - if (socket !== current) return + const selection = + message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined + if (selection?.success) { + setStore("selection", { ...selection.data, source: "websocket" }) + return + } - socket = undefined - pending.clear() - if (closed) return + const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined + if (mention?.success) { + mentionListeners.forEach((listener) => listener(mention.data)) + return + } - setStore("status", "connecting") - scheduleReconnect() - }) + if (typeof message.id !== "number") return + + const method = pending.get(message.id) + if (!method) return + + pending.delete(message.id) + if (message.error) return + + const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined + if (initialize?.success) { + setStore("server", initialize.data) + send({ method: "notifications/initialized" }) + return + } + }) + + current.addEventListener("close", () => { + if (socket !== current) return + + socket = undefined + pending.clear() + if (closed) return + + setStore("status", "connecting") + scheduleReconnect() + }) + } + + const scheduleReconnect = () => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + attempt += 1 + const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000) + reconnect = setTimeout(connect, delay) + } + + const scheduleZedPoll = () => { + if (closed) return + if (reconnect) clearTimeout(reconnect) + reconnect = setTimeout(connect, 1000) + } + + const reconnectWithDirectory = (nextDirectory?: string) => { + const resolved = nextDirectory || process.cwd() + if (directory === resolved) return + + directory = resolved + attempt = 0 + pending.clear() + lastZedSelectionKey = undefined + if (reconnect) clearTimeout(reconnect) + reconnect = undefined + if (socket) { + const current = socket + socket = undefined + current.close() } + setStore("status", "disabled") + setStore("selection", undefined) + setStore("server", undefined) + connect() + } + onMount(() => { connect() onCleanup(() => { @@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return { enabled() { - return Boolean(resolveEditorConnection() || resolveZedDbPath()) + return Boolean(resolveEditorConnection(directory) || resolveZedDbPath()) }, connected() { return store.status === "connected" @@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create server() { return store.server }, + reconnect(directory?: string) { + setStore("selection", undefined) + reconnectWithDirectory(directory) + }, } }, }) @@ -260,8 +287,16 @@ function parsePort(value: string | undefined) { return parsed } -function resolveEditorConnection(): EditorConnection | undefined { - const lock = resolveEditorLockFile() +function resolveEditorConnection(directory: string): EditorConnection | undefined { + const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT) + if (port) { + return { + url: `ws://127.0.0.1:${port}`, + source: `env:${port}`, + } + } + + const lock = resolveEditorLockFile(directory) if (lock) { return { url: `ws://127.0.0.1:${lock.port}`, @@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined { source: `lock:${lock.port}`, } } - - const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT) - if (!port) return - return { - url: `ws://127.0.0.1:${port}`, - source: `env:${port}`, - } } -function resolveEditorLockFile() { +function resolveEditorLockFile(activeDirectory: string) { const directory = path.join(os.homedir(), ".claude", "ide") let entries: string[] @@ -288,10 +316,9 @@ function resolveEditorLockFile() { return } - const cwd = process.cwd() - // longest workspace folder that contains cwd; 0 if none match + // longest workspace folder that contains the active session directory; 0 if none match const bestMatchLength = (lock: EditorLockFile) => - Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd))) + Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory))) const locks = entries .filter((entry) => entry.endsWith(".lock")) .map((entry) => readEditorLockFile(path.join(directory, entry))) @@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0 } -function openEditorSocket(connection: EditorConnection) { - if (!connection.authToken) return new WebSocket(connection.url) +function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) { + if (!connection.authToken) return new WebSocketImpl(connection.url) - return new WebSocket(connection.url, { + return new WebSocketImpl(connection.url, { headers: { "x-claude-code-ide-authorization": connection.authToken, }, 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 60343de4966a..8855338d1d4b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -50,6 +50,7 @@ import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" +import { useEditorContext } from "@tui/context/editor" import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" @@ -179,6 +180,7 @@ export function Session() { const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const toast = useToast() const sdk = useSDK() + const editor = useEditorContext() createEffect(() => { const sessionID = route.sessionID @@ -206,6 +208,7 @@ export function Session() { await sync.bootstrap({ fatal: false }) } catch {} } + editor.reconnect(result.data.directory) await sync.session.sync(sessionID) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts similarity index 100% rename from packages/opencode/test/cli/tui/editor-context.test.ts rename to packages/opencode/test/cli/tui/editor-context-zed.test.ts diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx new file mode 100644 index 000000000000..e896c29fb5b9 --- /dev/null +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -0,0 +1,224 @@ +import { mkdir, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, expect, spyOn, test } from "bun:test" +import { createRoot } from "solid-js" +import { EditorContextProvider, useEditorContext } from "../../../src/cli/cmd/tui/context/editor" +import { tmpdir } from "../../fixture/fixture" +import { FakeWebSocket } from "../../lib/websocket" + +const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT +const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT + +afterEach(() => { + process.env.CLAUDE_CODE_SSE_PORT = originalClaudePort + process.env.OPENCODE_EDITOR_SSE_PORT = originalOpencodePort +}) + +function nextTick() { + return new Promise((resolve) => queueMicrotask(resolve)) +} + +function mountEditorContext(WebSocketImpl?: typeof WebSocket) { + let editor!: ReturnType + let dispose!: () => void + + createRoot((nextDispose) => { + dispose = nextDispose + + const Consumer = () => { + editor = useEditorContext() + return null + } + + return ( + + + + ) + }) + + return { + editor, + dispose, + } +} + +function createWebSocketImpl(...sockets: FakeWebSocket[]) { + let index = 0 + + return class { + constructor(url: string, options?: { headers?: Record }) { + const socket = sockets[index] + index += 1 + expect(socket).toBeDefined() + expect(url).toBe(socket!.url) + expect(options).toEqual(socket!.options) + return socket as unknown as object + } + } as unknown as typeof WebSocket +} + +test("useEditorContext reconnect switches editor server by session directory", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const sessionDirectory = path.join(tmp.path, "session") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(sessionDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + await writeFile( + path.join(ideDirectory, "3002.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [sessionDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const firstSocket = new FakeWebSocket("ws://127.0.0.1:3001") + const secondSocket = new FakeWebSocket("ws://127.0.0.1:3002") + + const mounted = mountEditorContext(createWebSocketImpl(firstSocket, secondSocket)) + await nextTick() + + expect(firstSocket.closed).toBeFalse() + + mounted.editor.reconnect(sessionDirectory) + await nextTick() + + expect(firstSocket.closed).toBeTrue() + expect(secondSocket.closed).toBeFalse() + + mounted.dispose() +}) + +test("useEditorContext favors configured port over lock files", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = "4010" + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:4010") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + + mounted.dispose() +}) + +test("useEditorContext resets selection when reconnecting", async () => { + await using tmp = await tmpdir() + const startupDirectory = path.join(tmp.path, "startup") + const ideDirectory = path.join(tmp.path, ".claude", "ide") + await mkdir(startupDirectory, { recursive: true }) + await mkdir(ideDirectory, { recursive: true }) + await writeFile( + path.join(ideDirectory, "3001.lock"), + JSON.stringify({ + transport: "ws", + workspaceFolders: [startupDirectory], + }), + ) + + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = undefined + spyOn(process, "cwd").mockImplementation(() => startupDirectory) + spyOn(os, "homedir").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:3001") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + expect(mounted.editor.selection()).toBeUndefined() + expect(mounted.editor.connected()).toBeFalse() + + socket.open() + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2025-11-25", + serverInfo: { name: "test", version: "0.0.0" }, + }, + }), + ) + socket.message( + JSON.stringify({ + jsonrpc: "2.0", + method: "selection_changed", + params: { + text: "foo", + filePath: path.join(startupDirectory, "file.ts"), + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + }), + ) + + expect(mounted.editor.connected()).toBeTrue() + expect(mounted.editor.server()).toEqual({ + protocolVersion: "2025-11-25", + serverInfo: { name: "test", version: "0.0.0" }, + }) + expect(mounted.editor.selection()).toEqual({ + text: "foo", + filePath: path.join(startupDirectory, "file.ts"), + source: "websocket", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }) + + mounted.editor.reconnect(startupDirectory) + + expect(socket.closed).toBeFalse() + expect(mounted.editor.connected()).toBeTrue() + expect(mounted.editor.selection()).toBeUndefined() + + mounted.dispose() +}) + +test("useEditorContext connects with OPENCODE_EDITOR_SSE_PORT", async () => { + await using tmp = await tmpdir() + process.env.CLAUDE_CODE_SSE_PORT = undefined + process.env.OPENCODE_EDITOR_SSE_PORT = "4020" + spyOn(process, "cwd").mockImplementation(() => tmp.path) + const socket = new FakeWebSocket("ws://127.0.0.1:4020") + + const mounted = mountEditorContext(createWebSocketImpl(socket)) + await nextTick() + + expect(socket.closed).toBeFalse() + + mounted.dispose() +}) diff --git a/packages/opencode/test/lib/websocket.ts b/packages/opencode/test/lib/websocket.ts new file mode 100644 index 000000000000..7f7d7fba8cc6 --- /dev/null +++ b/packages/opencode/test/lib/websocket.ts @@ -0,0 +1,46 @@ +export class FakeWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + + readyState = FakeWebSocket.CONNECTING + closed = false + sent: string[] = [] + listeners = new Map void>>() + + constructor( + readonly url: string, + readonly options?: { headers?: Record }, + ) {} + + addEventListener(type: string, listener: (event: { data?: unknown }) => void) { + const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>() + current.add(listener) + this.listeners.set(type, current) + } + + send(data: string) { + this.sent.push(data) + } + + close() { + if (this.readyState === FakeWebSocket.CLOSED) return + this.closed = true + this.readyState = FakeWebSocket.CLOSED + this.emit("close", {}) + } + + open() { + this.readyState = FakeWebSocket.OPEN + this.emit("open", {}) + } + + message(data: unknown) { + this.emit("message", { data }) + } + + emit(type: string, event: { data?: unknown }) { + this.listeners.get(type)?.forEach((listener) => listener(event)) + } +} From 9db5890ce5e50ac3aa98c747acc72a10af4fc29c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 16:50:54 -0400 Subject: [PATCH 0010/1114] Refactor HttpApi workspace routing and proxy boundaries (#25006) --- packages/opencode/AGENTS.md | 1 + packages/opencode/src/server/proxy-util.ts | 50 +++++ packages/opencode/src/server/proxy.ts | 120 ++++------ .../routes/instance/httpapi/groups/config.ts | 6 +- .../instance/httpapi/groups/experimental.ts | 6 +- .../routes/instance/httpapi/groups/file.ts | 6 +- .../instance/httpapi/groups/instance.ts | 6 +- .../routes/instance/httpapi/groups/mcp.ts | 6 +- .../instance/httpapi/groups/permission.ts | 6 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 6 +- .../instance/httpapi/groups/question.ts | 6 +- .../routes/instance/httpapi/groups/session.ts | 6 +- .../routes/instance/httpapi/groups/sync.ts | 6 +- .../routes/instance/httpapi/groups/tui.ts | 6 +- .../instance/httpapi/groups/workspace.ts | 6 +- .../instance/httpapi/instance-context.ts | 212 ------------------ .../{auth.ts => middleware/authorization.ts} | 0 .../httpapi/middleware/instance-context.ts | 55 +++++ .../instance/httpapi/middleware/proxy.ts | 86 +++++++ .../httpapi/middleware/workspace-routing.ts | 212 ++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 20 +- .../test/server/httpapi-workspace.test.ts | 194 +++++++++++++--- .../opencode/test/server/proxy-util.test.ts | 113 ++++++++++ .../test/server/workspace-proxy.test.ts | 93 ++++++++ .../test/server/workspace-routing.test.ts | 85 +++++++ 27 files changed, 980 insertions(+), 345 deletions(-) create mode 100644 packages/opencode/src/server/proxy-util.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/instance-context.ts rename packages/opencode/src/server/routes/instance/httpapi/{auth.ts => middleware/authorization.ts} (100%) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts create mode 100644 packages/opencode/test/server/proxy-util.test.ts create mode 100644 packages/opencode/test/server/workspace-proxy.test.ts create mode 100644 packages/opencode/test/server/workspace-routing.test.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index d7fb844f0d19..2a39b6c144d6 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -78,6 +78,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples. - Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. - `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. - Use `Effect.callback` for callback-based APIs. +- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. ## Module conventions diff --git a/packages/opencode/src/server/proxy-util.ts b/packages/opencode/src/server/proxy-util.ts new file mode 100644 index 000000000000..43d6efb2f96d --- /dev/null +++ b/packages/opencode/src/server/proxy-util.ts @@ -0,0 +1,50 @@ +const hop = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", +]) + +function sanitize(out: Headers) { + for (const key of hop) out.delete(key) + out.delete("accept-encoding") + out.delete("x-opencode-directory") + out.delete("x-opencode-workspace") +} + +export function headers(input: Request | HeadersInit | Record, extra?: HeadersInit) { + const raw = input instanceof Request ? input.headers : input + const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record)) + sanitize(out) + if (!extra) return out + for (const [key, value] of new Headers(extra).entries()) { + out.set(key, value) + } + return out +} + +export function websocketProtocols(input: Request | Record) { + const value = input instanceof Request + ? input.headers.get("sec-websocket-protocol") + : input["sec-websocket-protocol"] + if (!value) return [] + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +} + +export function websocketTargetURL(url: string | URL) { + const next = new URL(url) + if (next.protocol === "http:") next.protocol = "ws:" + if (next.protocol === "https:") next.protocol = "wss:" + return next.toString() +} + +export * as ProxyUtil from "./proxy-util" diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index f93150020d09..8541d39f4959 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -4,51 +4,12 @@ import * as Log from "@opencode-ai/core/util/log" import * as Fence from "./fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" - -const hop = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "host", -]) +import { ProxyUtil } from "./proxy-util" +import { Effect, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" type Msg = string | ArrayBuffer | Uint8Array -function headers(req: Request, extra?: HeadersInit) { - const out = new Headers(req.headers) - for (const key of hop) out.delete(key) - out.delete("accept-encoding") - out.delete("x-opencode-directory") - out.delete("x-opencode-workspace") - if (!extra) return out - for (const [key, value] of new Headers(extra).entries()) { - out.set(key, value) - } - return out -} - -export function websocketProtocols(req: Request) { - const value = req.headers.get("sec-websocket-protocol") - if (!value) return [] - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean) -} - -export function websocketTargetURL(url: string | URL) { - const next = new URL(url) - if (next.protocol === "http:") next.protocol = "ws:" - if (next.protocol === "https:") next.protocol = "wss:" - return next.toString() -} - function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) { if (data instanceof Blob) { return data.arrayBuffer().then((x) => ws.send(x)) @@ -69,7 +30,7 @@ const app = (upgrade: UpgradeWebSocket) => ws.close(1011, "missing proxy target") return } - remote = new WebSocket(url, websocketProtocols(c.req.raw)) + remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw)) remote.binaryType = "arraybuffer" remote.onopen = () => { for (const item of queue) remote?.send(item) @@ -103,40 +64,57 @@ const app = (upgrade: UpgradeWebSocket) => const log = Log.create({ service: "server-proxy" }) -export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { +function statusText(response: unknown) { + return (response as { source?: Response }).source?.statusText +} + +export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { if (!Workspace.isSyncing(workspaceID)) { - return new Response(`broken sync connection for workspace: ${workspaceID}`, { - status: 503, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) + return Effect.succeed( + new Response(`broken sync connection for workspace: ${workspaceID}`, { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }), + ) } - return fetch( - new Request(url, { - method: req.method, - headers: headers(req, extra), - body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, - redirect: "manual", - signal: req.signal, - }), - ).then((res) => { - const sync = Fence.parse(res.headers) - const next = new Headers(res.headers) + return Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.make(req.method as never)(url, { + headers: ProxyUtil.headers(req, extra), + body: + req.method === "GET" || req.method === "HEAD" + ? HttpBody.empty + : HttpBody.raw(req.body, { + contentType: req.headers.get("content-type") ?? undefined, + contentLength: req.headers.get("content-length") + ? Number(req.headers.get("content-length")) + : undefined, + }), + }), + ) + const next = new Headers(response.headers as HeadersInit) + const sync = Fence.parse(next) next.delete("content-encoding") next.delete("content-length") - const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve() - - return done.then(async () => { - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: next, - }) + if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal)) + const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) + return new Response(body, { + status: response.status, + statusText: statusText(response), + headers: next, }) - }) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))), + ) +} + +export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { + return Effect.runPromise(httpEffect(url, extra, req, workspaceID)) } export function websocket( @@ -150,7 +128,7 @@ export function websocket( proxy.pathname = "/__workspace_ws" proxy.search = "" const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", websocketTargetURL(target)) + next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target)) for (const [key, value] of new Headers(extra).entries()) { next.set(key, value) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index 4ff406e2a416..fa77785a9bc3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,8 +1,9 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -48,6 +49,7 @@ export const ConfigApi = HttpApi.make("config") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 2a562b46b3a9..e4a86ca1394d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -6,8 +6,9 @@ import { Worktree } from "@/worktree" import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -201,6 +202,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 3a4f3df7f9c9..b950adb383e3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -3,8 +3,9 @@ import { Ripgrep } from "@/file/ripgrep" import { LSP } from "@/lsp/lsp" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ @@ -108,6 +109,7 @@ export const FileApi = HttpApi.make("file") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index cc450f448c27..463ea1ae4c83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -6,8 +6,9 @@ import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -130,6 +131,7 @@ export const InstanceApi = HttpApi.make("instance") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index 149f8814a912..e9caf0cd9d7d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -2,8 +2,9 @@ import { MCP } from "@/mcp" import { ConfigMCP } from "@/config/mcp" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -131,6 +132,7 @@ export const McpApi = HttpApi.make("mcp") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index e06c98d9eff2..22c4d6f6d32b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -2,8 +2,9 @@ import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -45,6 +46,7 @@ export const PermissionApi = HttpApi.make("permission") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 92019866e9e2..1a2084547d0b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -2,8 +2,9 @@ import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -64,6 +65,7 @@ export const ProjectApi = HttpApi.make("project") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 56dace0e5ead..4a9bbffc549c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -3,8 +3,9 @@ import { Provider } from "@/provider/provider" import { ProviderID } from "@/provider/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -63,6 +64,7 @@ export const ProviderApi = HttpApi.make("provider") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( 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 eb71526fb3e2..d54bda4a84a6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -2,8 +2,9 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/pty" @@ -95,6 +96,7 @@ export const PtyApi = HttpApi.make("pty") ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de249823b7ef..de2d4fca8ecf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -2,8 +2,9 @@ import { Question } from "@/question" import { QuestionID } from "@/question/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -57,6 +58,7 @@ export const QuestionApi = HttpApi.make("question") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 5a388f187682..bc26a9e59724 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -13,8 +13,9 @@ import { Snapshot } from "@/snapshot" import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/session" @@ -417,6 +418,7 @@ export const SessionApi = HttpApi.make("session") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( 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 1d9b08d9cb83..58d30b4c787b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,8 +1,9 @@ import { NonNegativeInt } from "@/util/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -79,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 49ba05c2d517..efe73d95d1fc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,8 +1,9 @@ import { TuiEvent } from "@/cli/cmd/tui/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +import { Authorization } from "../middleware/authorization" +import { InstanceContextMiddleware } from "../middleware/instance-context" +import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/tui" @@ -184,6 +185,7 @@ export const TuiApi = HttpApi.make("tui") ) .annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( 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 ab5f08bb1729..268e84f2ecc4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -3,8 +3,9 @@ import { WorkspaceAdaptorEntry } 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 { Authorization } from "../auth" -import { InstanceContextMiddleware } from "../instance-context" +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" @@ -90,6 +91,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ) .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) .middleware(InstanceContextMiddleware) + .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization), ) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts deleted file mode 100644 index 42e973020df5..000000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/instance-context.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { getAdaptor } from "@/control-plane/adaptors" -import { WorkspaceID } from "@/control-plane/schema" -import type { Target } from "@/control-plane/types" -import { Workspace } from "@/control-plane/workspace" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import { Session } from "@/session/session" -import { ServerProxy } from "@/server/proxy" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" -import { Filesystem } from "@/util/filesystem" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Effect, Layer } from "effect" -import type { unhandled } from "effect/Types" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" -import * as Socket from "effect/unstable/socket/Socket" - -type HandlerEffect = Effect.Effect - -export class InstanceContextMiddleware extends HttpApiMiddleware.Service< - InstanceContextMiddleware, - { - requires: Session.Service - } ->()("@opencode/ExperimentalHttpApiInstanceContext") {} - -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} - -function currentDirectory() { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - -function sourceRequest(request: HttpServerRequest.HttpServerRequest) { - if (request.source instanceof Request) return request.source - return new Request(new URL(request.originalUrl, "http://localhost"), { - method: request.method, - headers: request.headers as HeadersInit, - }) -} - -function requestHeaders(request: HttpServerRequest.HttpServerRequest) { - return sourceRequest(request).headers -} - -function writeSocket( - write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect, - data: unknown, -) { - if (data instanceof Blob) { - void data - .arrayBuffer() - .then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void)))) - return - } - if (typeof data === "string" || data instanceof Uint8Array) { - Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void))) - return - } - if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void))) -} - -function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) { - return Effect.gen(function* () { - const source = sourceRequest(request) - const socket = yield* Effect.orDie(request.upgrade) - const write = yield* socket.writer - const queue: Array = [] - const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source)) - remote.binaryType = "arraybuffer" - remote.onopen = () => { - for (const item of queue) remote.send(item) - queue.length = 0 - } - remote.onmessage = (event) => writeSocket(write, event.data) - remote.onerror = () => - Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))) - remote.onclose = (event) => - Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void))) - - yield* socket - .runRaw((message) => { - const data = typeof message === "string" ? message : message.slice() - if (remote.readyState === WebSocket.OPEN) { - remote.send(data) - return - } - queue.push(data) - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring(Effect.sync(() => remote.close())), - Effect.orDie, - ) - return HttpServerResponse.empty() - }) -} - -function proxyRemote( - request: HttpServerRequest.HttpServerRequest, - workspace: Workspace.Info, - target: Extract, - requestURL: URL, -) { - const url = workspaceProxyURL(target.url, requestURL) - const source = sourceRequest(request) - if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url) - return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe( - Effect.map(HttpServerResponse.raw), - ) -} - -function requestContext() { - return Effect.withFiber((fiber) => - Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)), - ) -} - -function provideRequestContext( - effect: HandlerEffect, - request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, -) { - return Effect.gen(function* () { - const url = new URL(request.url, "http://localhost") - const headers = requestHeaders(request) - const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined - const workspaceParam = url.searchParams.get("workspace") - const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) - const workspace = - workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined - - if (workspaceID && !workspace && !envWorkspaceID) { - return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, { - status: 500, - contentType: "text/plain; charset=utf-8", - }) - } - - if ( - workspace && - !isLocalWorkspaceRoute(request.method, url.pathname) && - !url.pathname.startsWith("/console") && - !envWorkspaceID - ) { - const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) - if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url) - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, workspace.id), - ) - } - - const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory() - const ctx = yield* Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(raw)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) - - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID), - ) - }) -} - -function provideInstanceContext(effect: HandlerEffect) { - return Effect.gen(function* () { - const request = yield* requestContext() - const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost")) - const session = sessionID - ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe( - Effect.catch(() => Effect.succeed(undefined)), - Effect.catchDefect(() => Effect.succeed(undefined)), - ) - : undefined - return yield* provideRequestContext(effect, request, session?.workspaceID) - }) -} - -export const instanceContextLayer = Layer.succeed( - InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), -) - -export const instanceRouterLayer = HttpRouter.middleware()( - Effect.succeed((effect) => - requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))), - ), -).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/auth.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/auth.ts rename to packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts 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 new file mode 100644 index 000000000000..c80f1caeb65d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -0,0 +1,55 @@ +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 { Effect, Layer } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { WorkspaceRouteContext } from "./workspace-routing" + +export class InstanceContextMiddleware extends HttpApiMiddleware.Service< + InstanceContextMiddleware, + { + requires: WorkspaceRouteContext + } +>()("@opencode/ExperimentalHttpApiInstanceContext") {} + +function decode(input: string): string { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function makeInstanceContext(directory: string): Effect.Effect { + return Effect.promise(() => + Instance.provide({ + directory: Filesystem.resolve(decode(directory)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) +} + +function provideInstanceContext( + effect: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + const ctx = yield* makeInstanceContext(route.directory) + return yield* effect.pipe( + Effect.provideService(InstanceRef, ctx), + Effect.provideService(WorkspaceRef, route.workspaceID), + ) + }) +} + +export const instanceContextLayer = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), +) + +export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts new file mode 100644 index 000000000000..a2153ce71436 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -0,0 +1,86 @@ +import { ProxyUtil } from "@/server/proxy-util" +import { Effect, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" + +function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { + return request.source instanceof Request ? request.source : undefined +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.raw(webSource(request)?.body ?? null, { + contentType: request.headers["content-type"], + contentLength: len ? Number(len) : undefined, + }) +} + +export function websocket( + request: HttpServerRequest.HttpServerRequest, + target: string | URL, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const inbound = yield* Effect.orDie(request.upgrade) + const outbound = yield* Socket.makeWebSocket(ProxyUtil.websocketTargetURL(target), { + protocols: ProxyUtil.websocketProtocols(request.headers), + }) + const writeInbound = yield* inbound.writer + const writeOutbound = yield* outbound.writer + + yield* outbound + .runRaw((message) => writeInbound(message)) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", (reason) => + writeInbound(new Socket.CloseEvent(reason.code, reason.closeReason)).pipe(Effect.catch(() => Effect.void)), + ), + Effect.catch(() => writeInbound(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))), + Effect.forkScoped, + ) + + yield* inbound + .runRaw((message) => { + return writeOutbound(typeof message === "string" ? message : message.slice()) + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.ensuring(writeOutbound(new Socket.CloseEvent()).pipe(Effect.catch(() => Effect.void))), + ) + return HttpServerResponse.empty() + }).pipe(Effect.orDie), + ) +} + +function statusText(response: unknown) { + return (response as { source?: Response }).source?.statusText +} + +export function http( + url: string | URL, + extra: HeadersInit | undefined, + request: HttpServerRequest.HttpServerRequest, +): Effect.Effect { + return Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.make(request.method as never)(url, { + headers: ProxyUtil.headers(request.headers as HeadersInit, extra), + body: requestBody(request), + }), + ) + const headers = new Headers(response.headers as HeadersInit) + headers.delete("content-encoding") + headers.delete("content-length") + + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + statusText: statusText(response), + headers, + }) + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))), + ) +} + +export * as HttpApiProxy from "./proxy" 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 new file mode 100644 index 000000000000..4b68242b5799 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -0,0 +1,212 @@ +import { getAdaptor } from "@/control-plane/adaptors" +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" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Context, Data, Effect, Layer } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" + +type RemoteTarget = Extract + +type RequestPlan = Data.TaggedEnum<{ + MissingWorkspace: { readonly workspaceID: WorkspaceID } + Local: { readonly directory: string; readonly workspaceID?: WorkspaceID } + Remote: { + readonly request: HttpServerRequest.HttpServerRequest + readonly workspace: Workspace.Info + readonly target: RemoteTarget + readonly url: URL + } +}> +const RequestPlan = Data.taggedEnum() + +export class WorkspaceRouteContext extends Context.Service()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {} + +export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< + WorkspaceRoutingMiddleware, + { + provides: WorkspaceRouteContext + requires: Session.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") +} + +function configuredWorkspaceID(): WorkspaceID | undefined { + return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined +} + +function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined { + const workspaceParam = url.searchParams.get("workspace") + return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) +} + +function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory() +} + +function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { + return isLocalWorkspaceRoute(request.method, url.pathname) || url.pathname.startsWith("/console") +} + +function resolveWorkspace( + id: WorkspaceID | undefined, + envWorkspaceID: WorkspaceID | undefined, +): Effect.Effect { + if (!id || envWorkspaceID) return Effect.void + return Effect.promise(() => Workspace.get(id)) +} + +function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse { + return HttpServerResponse.text(`Workspace not found: ${id}`, { + status: 500, + contentType: "text/plain; charset=utf-8", + }) +} + +function resolveTarget(workspace: Workspace.Info): Effect.Effect { + return Effect.gen(function* () { + const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + }) +} + +function proxyRemote( + request: HttpServerRequest.HttpServerRequest, + workspace: Workspace.Info, + target: RemoteTarget, + url: URL, +): Effect.Effect { + return Effect.gen(function* () { + const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id)) + if (!syncing) { + return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { + status: 503, + contentType: "text/plain; charset=utf-8", + }) + } + const proxyURL = workspaceProxyURL(target.url, url) + const headers = request.headers as Record + if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) + const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) + const sync = Fence.parse(new Headers(response.headers)) + if (sync) yield* Effect.promise(() => Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined)) + return response + }) +} + +function planWorkspaceRequest( + request: HttpServerRequest.HttpServerRequest, + url: URL, + workspace: Workspace.Info, +): Effect.Effect { + return Effect.gen(function* () { + const target = yield* resolveTarget(workspace) + if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url }) + return RequestPlan.Local({ directory: target.directory, workspaceID: workspace.id }) + }) +} + +function planRequest( + request: HttpServerRequest.HttpServerRequest, + sessionWorkspaceID?: WorkspaceID, +): Effect.Effect { + return Effect.gen(function* () { + const url = requestURL(request) + const envWorkspaceID = configuredWorkspaceID() + const workspaceID = selectedWorkspaceID(url, sessionWorkspaceID) + const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) + + if (workspaceID && workspace === undefined && !envWorkspaceID) { + return RequestPlan.MissingWorkspace({ workspaceID }) + } + + if (workspace !== undefined && !envWorkspaceID && !shouldStayOnControlPlane(request, url)) { + return yield* planWorkspaceRequest(request, url, workspace) + } + + return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID }) + }) +} + +function routeWorkspace( + effect: Effect.Effect, + plan: RequestPlan, +): Effect.Effect { + return RequestPlan.$match(plan, { + MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), + Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), + Local: ({ directory, workspaceID }) => + effect.pipe( + Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID })), + ), + }) +} + +function routeWorkspaceRequest( + effect: Effect.Effect, + request: HttpServerRequest.HttpServerRequest, + sessionWorkspaceID?: WorkspaceID, +): Effect.Effect { + return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan)) +} + +function routeHttpApiWorkspace( + effect: Effect.Effect, +): Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor +> { + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const sessionID = getWorkspaceRouteSessionID(requestURL(request)) + const session = sessionID + ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) + : undefined + return yield* routeWorkspaceRequest(effect, request, session?.workspaceID) + }) +} + +export const workspaceRoutingLayer = Layer.effect( + WorkspaceRoutingMiddleware, + Effect.gen(function* () { + const makeWebSocket = yield* Socket.WebSocketConstructor + return WorkspaceRoutingMiddleware.of((effect) => + routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)), + ) + }), +) + +export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()( + Effect.gen(function* () { + const makeWebSocket = yield* Socket.WebSocketConstructor + return (effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* routeWorkspaceRequest(effect, request).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + ) + }) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 2f4bde918399..144ba0c63242 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,7 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -31,7 +32,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { InstanceHttpApi, RootHttpApi } from "./api" -import { authorizationLayer } from "./auth" +import { authorizationLayer } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -49,7 +50,8 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { workspaceHandlers } from "./handlers/workspace" -import { instanceContextLayer, instanceRouterLayer } from "./instance-context" +import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" +import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" @@ -86,9 +88,19 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) +const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( + Layer.provide( + instanceRouterMiddleware.combine(workspaceRouterMiddleware).layer.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + ), + ), +) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( - Layer.provide([authorizationLayer, instanceContextLayer]), + Layer.provide([ + authorizationLayer, + workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), + instanceContextLayer, + ]), ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 5ce531dcc22e..74dfbaef8609 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterEach, describe, expect, mock, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" @@ -14,6 +14,7 @@ import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspaceRef } from "../../src/effect/instance-ref" void Log.init({ print: false }) @@ -27,8 +28,13 @@ function request(path: string, directory: string, init: RequestInit = {}) { return Server.Default().app.request(path, { ...init, headers }) } -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +function runSession(fx: Effect.Effect, workspaceID?: Workspace.Info["id"]) { + return Effect.runPromise( + fx.pipe( + workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect, + Effect.provide(Session.defaultLayer), + ), + ) } function localAdaptor(directory: string): WorkspaceAdaptor { @@ -55,7 +61,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { +function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { return { name: "Remote Test", description: "Create a remote test workspace", @@ -74,20 +80,51 @@ function remoteAdaptor(directory: string, url: string): WorkspaceAdaptor { return { type: "remote" as const, url, + headers, } }, } } -function eventStreamResponse() { - return new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { - "content-type": "text/event-stream", +type ProxiedRequest = { + url: string + method: string + headers: Record + body: string +} + +function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise) { + return Bun.serve({ + port: 0, + async fetch(request) { + return handler({ + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: await request.text(), + }) }, }) } +function eventStreamResponse() { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'), + ) + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ) +} + afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces @@ -97,6 +134,8 @@ afterEach(async () => { }) describe("workspace HttpApi", () => { + test.todo("proxies remote workspace websocket through real Effect listener", () => {}) + test("serves read endpoints", async () => { await using tmp = await tmpdir({ git: true }) @@ -191,25 +230,33 @@ describe("workspace HttpApi", () => { } }) - test("proxies remote workspace HTTP requests", async () => { + test("proxies remote workspace HTTP requests with sanitized forwarding", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true }) - const proxied: string[] = [] - const rawFetch = globalThis.fetch - spyOn(globalThis, "fetch").mockImplementation( - Object.assign( - async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - proxied.push(url.toString()) - return Response.json({ proxied: true, path: url.pathname, workspace: url.searchParams.get("workspace") }) - }, + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return new Response( + JSON.stringify({ + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }), { - preconnect: rawFetch.preconnect?.bind(rawFetch), + status: 201, + statusText: "Created", + headers: { + "content-length": "999", + "content-type": "application/json", + "x-remote": "yes", + }, }, - ) as typeof globalThis.fetch, - ) + ) + }) const workspace = await Instance.provide({ directory: tmp.path, @@ -217,7 +264,9 @@ describe("workspace HttpApi", () => { registerAdaptor( Instance.project.id, "remote-target", - remoteAdaptor(path.join(tmp.path, ".remote"), "https://remote.test/base"), + remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + "x-target-auth": "secret", + }), ) return Workspace.create({ type: "remote-target", @@ -228,16 +277,101 @@ describe("workspace HttpApi", () => { }, }) - const url = new URL(`http://localhost${InstancePaths.path}`) + const url = new URL("http://localhost/config") url.searchParams.set("workspace", workspace.id) + url.searchParams.set("keep", "yes") try { - const response = await request(url.toString(), tmp.path) + const response = await request(url.toString(), tmp.path, { + method: "PATCH", + headers: { + "accept-encoding": "br", + "content-type": "application/json", + "x-opencode-workspace": "internal", + }, + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ proxied: true, path: "/base/path", workspace: null }) - expect(proxied).toEqual(["https://remote.test/base/path"]) + const responseBody = await response.text() + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) + expect(response.headers.get("content-length")).toBeNull() + expect(response.headers.get("x-remote")).toBe("yes") + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) + const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") + expect(forwarded).toEqual([ + { + url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-target-auth": "secret", + }), + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }, + ]) + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") + } finally { + remote.stop(true) + await Workspace.remove(workspace.id) + } + }) + + test("proxies remote workspace requests selected from session ownership", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + await using tmp = await tmpdir({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return Response.json({ proxied: true, path: new URL(request.url).pathname }) + }) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + registerAdaptor( + Instance.project.id, + "remote-session-target", + remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + ) + return Workspace.create({ + type: "remote-session-target", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + const session = await Instance.provide({ + directory: tmp.path, + fn: async () => + runSession( + Session.Service.use((svc) => svc.create()), + workspace.id, + ), + }) + + try { + const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }) + + const responseBody = await response.text() + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + method: "POST", + }), + ]) } finally { + remote.stop(true) await Workspace.remove(workspace.id) } }) diff --git a/packages/opencode/test/server/proxy-util.test.ts b/packages/opencode/test/server/proxy-util.test.ts new file mode 100644 index 000000000000..d13a06bb31a8 --- /dev/null +++ b/packages/opencode/test/server/proxy-util.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { ProxyUtil } from "../../src/server/proxy-util" + +describe("ProxyUtil", () => { + describe("websocketTargetURL", () => { + test("converts http to ws", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path")).toBe("ws://example.com/path") + }) + + test("converts https to wss", () => { + expect(ProxyUtil.websocketTargetURL("https://example.com/path")).toBe("wss://example.com/path") + }) + + test("preserves query params", () => { + expect(ProxyUtil.websocketTargetURL("http://example.com/path?foo=bar")).toBe("ws://example.com/path?foo=bar") + }) + + test("accepts URL objects", () => { + expect(ProxyUtil.websocketTargetURL(new URL("http://localhost:3000/ws"))).toBe("ws://localhost:3000/ws") + }) + }) + + describe("websocketProtocols", () => { + test("returns empty array when no header", () => { + const req = new Request("http://localhost") + expect(ProxyUtil.websocketProtocols(req)).toEqual([]) + }) + + test("parses single protocol", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws"]) + }) + + test("parses multiple protocols", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": "graphql-ws, graphql-transport-ws" }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws", "graphql-transport-ws"]) + }) + + test("trims whitespace and filters empty", () => { + const req = new Request("http://localhost", { + headers: { "sec-websocket-protocol": " proto1 , , proto2 " }, + }) + expect(ProxyUtil.websocketProtocols(req)).toEqual(["proto1", "proto2"]) + }) + }) + + describe("headers", () => { + test("strips hop-by-hop headers", () => { + const req = new Request("http://localhost", { + headers: { + connection: "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + "content-type": "application/json", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("connection")).toBeNull() + expect(result.get("keep-alive")).toBeNull() + expect(result.get("transfer-encoding")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + }) + + test("strips opencode-specific headers", () => { + const req = new Request("http://localhost", { + headers: { + "x-opencode-directory": "/home/user/project", + "x-opencode-workspace": "ws_123", + "accept-encoding": "gzip", + "x-custom": "keep", + }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("x-opencode-directory")).toBeNull() + expect(result.get("x-opencode-workspace")).toBeNull() + expect(result.get("accept-encoding")).toBeNull() + expect(result.get("x-custom")).toBe("keep") + }) + + test("merges extra headers", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json" }, + }) + const result = ProxyUtil.headers(req, { "x-auth": "token", "content-type": "text/plain" }) + expect(result.get("x-auth")).toBe("token") + expect(result.get("content-type")).toBe("text/plain") + }) + + test("returns original headers when no extra", () => { + const req = new Request("http://localhost", { + headers: { "content-type": "application/json", "x-foo": "bar" }, + }) + const result = ProxyUtil.headers(req) + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-foo")).toBe("bar") + }) + + test("accepts plain object (HeadersInit) as input", () => { + const result = ProxyUtil.headers( + { "content-type": "application/json", connection: "keep-alive", "x-custom": "val" }, + { "x-extra": "added" }, + ) + expect(result.get("connection")).toBeNull() + expect(result.get("content-type")).toBe("application/json") + expect(result.get("x-custom")).toBe("val") + expect(result.get("x-extra")).toBe("added") + }) + }) +}) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts new file mode 100644 index 000000000000..14f5bd06d647 --- /dev/null +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -0,0 +1,93 @@ +import { NodeHttpServer } from "@effect/platform-node" +import Http from "node:http" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" +import { testEffect } from "../lib/effect" + +function serverUrl() { + return Effect.gen(function* () { + return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + }) +} + +const testServerLayer = NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }) +const it = testEffect(testServerLayer) + +describe("HttpApi workspace proxy", () => { + it.live("proxies HTTP request and returns streamed response with status and headers", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const body = yield* req.text + return yield* HttpServerResponse.json({ path: req.url, method: req.method, body }, { + status: 201, + headers: { + "content-encoding": "identity", + "content-length": "999", + "x-remote": "yes", + }, + }) + }), + ) + const url = yield* serverUrl() + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), + ) + const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) + + expect(response.status).toBe(201) + const client = HttpServerResponse.toClientResponse(response) + expect(yield* client.json).toEqual({ + path: "/session/abc?keep=yes", + method: "POST", + body: "request-body", + }) + expect(response.headers["x-remote"]).toBe("yes") + expect(response.headers["content-encoding"]).toBeUndefined() + expect(response.headers["content-length"]).toBeUndefined() + }), + ) + + it.live("returns 500 when remote is unreachable", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) + const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request) + + expect(response.status).toBe(500) + }), + ) + + it.live("strips opencode-internal headers and merges extra headers", () => + Effect.gen(function* () { + let forwarded: Record = {} + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + forwarded = req.headers + return HttpServerResponse.empty() + }), + ) + const url = yield* serverUrl() + + const request = HttpServerRequest.fromWeb( + new Request("http://localhost/test", { + headers: { + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "ws_123", + "x-custom": "preserved", + }, + }), + ) + yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request) + + expect(forwarded["x-opencode-directory"]).toBeUndefined() + expect(forwarded["x-opencode-workspace"]).toBeUndefined() + expect(forwarded["x-custom"]).toBe("preserved") + expect(forwarded["x-injected"]).toBe("extra") + }), + ) +}) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts new file mode 100644 index 000000000000..22c44a6dffe2 --- /dev/null +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { SessionID } from "../../src/session/schema" + +describe("isLocalWorkspaceRoute", () => { + test("GET /session is local", () => { + expect(isLocalWorkspaceRoute("GET", "/session")).toBe(true) + }) + + test("GET /session/ses_abc is local (prefix match)", () => { + expect(isLocalWorkspaceRoute("GET", "/session/ses_abc")).toBe(true) + }) + + test("POST /session is not local (method mismatch)", () => { + expect(isLocalWorkspaceRoute("POST", "/session")).toBe(false) + }) + + test("/session/status is forwarded regardless of method", () => { + expect(isLocalWorkspaceRoute("GET", "/session/status")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/status")).toBe(false) + }) + + test("unrecognized paths are not local", () => { + expect(isLocalWorkspaceRoute("GET", "/config")).toBe(false) + expect(isLocalWorkspaceRoute("POST", "/session/ses_abc/message")).toBe(false) + }) +}) + +describe("getWorkspaceRouteSessionID", () => { + test("extracts session ID from path", () => { + const url = new URL("http://localhost/session/ses_abc123/message") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_abc123")) + }) + + test("extracts session ID without trailing path", () => { + const url = new URL("http://localhost/session/ses_xyz") + expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz")) + }) + + test("returns null for /session/status", () => { + const url = new URL("http://localhost/session/status") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for non-session paths", () => { + const url = new URL("http://localhost/config") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) + + test("returns null for bare /session path", () => { + const url = new URL("http://localhost/session") + expect(getWorkspaceRouteSessionID(url)).toBeNull() + }) +}) + +describe("workspaceProxyURL", () => { + test("appends request path to target", () => { + const result = workspaceProxyURL("http://remote:8080/base", new URL("http://localhost/config")) + expect(result.toString()).toBe("http://remote:8080/base/config") + }) + + test("strips trailing slash on target before appending", () => { + const result = workspaceProxyURL("http://remote:8080/base/", new URL("http://localhost/session/abc")) + expect(result.pathname).toBe("/base/session/abc") + }) + + test("preserves query params from request but removes workspace", () => { + const url = new URL("http://localhost/config?workspace=ws_123&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("workspace")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + + test("preserves hash from request", () => { + const url = new URL("http://localhost/page#section") + const result = workspaceProxyURL("http://remote:8080", url) + expect(result.hash).toBe("#section") + }) + + test("works with URL object as target", () => { + const target = new URL("http://remote:3000/api") + const result = workspaceProxyURL(target, new URL("http://localhost/users")) + expect(result.toString()).toBe("http://remote:3000/api/users") + }) +}) From 1124ae17b4b6d9a5f5bbf94213de67a85fcf97ec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Apr 2026 20:52:19 +0000 Subject: [PATCH 0011/1114] chore: generate --- packages/opencode/src/server/proxy-util.ts | 4 +--- .../instance/httpapi/middleware/proxy.ts | 13 ++++++++++-- .../httpapi/middleware/workspace-routing.ts | 20 +++++++++++-------- .../server/routes/instance/httpapi/server.ts | 6 +++--- .../test/server/workspace-proxy.test.ts | 17 +++++++++------- 5 files changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/server/proxy-util.ts b/packages/opencode/src/server/proxy-util.ts index 43d6efb2f96d..5107f4759a88 100644 --- a/packages/opencode/src/server/proxy-util.ts +++ b/packages/opencode/src/server/proxy-util.ts @@ -30,9 +30,7 @@ export function headers(input: Request | HeadersInit | Record, e } export function websocketProtocols(input: Request | Record) { - const value = input instanceof Request - ? input.headers.get("sec-websocket-protocol") - : input["sec-websocket-protocol"] + const value = input instanceof Request ? input.headers.get("sec-websocket-protocol") : input["sec-websocket-protocol"] if (!value) return [] return value .split(",") 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 a2153ce71436..549dac40cc97 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -1,6 +1,13 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientRequest, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { @@ -35,7 +42,9 @@ export function websocket( Effect.catchReason("SocketError", "SocketCloseError", (reason) => writeInbound(new Socket.CloseEvent(reason.code, reason.closeReason)).pipe(Effect.catch(() => Effect.void)), ), - Effect.catch(() => writeInbound(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void))), + Effect.catch(() => + writeInbound(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void)), + ), Effect.forkScoped, ) 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 4b68242b5799..9318dbfe5a66 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 @@ -27,10 +27,13 @@ type RequestPlan = Data.TaggedEnum<{ }> const RequestPlan = Data.taggedEnum() -export class WorkspaceRouteContext extends Context.Service()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {} +export class WorkspaceRouteContext extends Context.Service< + WorkspaceRouteContext, + { + readonly directory: string + readonly workspaceID?: WorkspaceID + } +>()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {} export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< WorkspaceRoutingMiddleware, @@ -110,7 +113,10 @@ function proxyRemote( if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) const sync = Fence.parse(new Headers(response.headers)) - if (sync) yield* Effect.promise(() => Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined)) + if (sync) + yield* Effect.promise(() => + Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined), + ) return response }) } @@ -157,9 +163,7 @@ function routeWorkspace( MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), Local: ({ directory, workspaceID }) => - effect.pipe( - Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID })), - ), + effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), }) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 144ba0c63242..c0fb5a20a081 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -90,9 +90,9 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( Layer.provide( - instanceRouterMiddleware.combine(workspaceRouterMiddleware).layer.pipe( - Layer.provide(Socket.layerWebSocketConstructorGlobal), - ), + instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), ), ) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 14f5bd06d647..549e700d1e84 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -22,14 +22,17 @@ describe("HttpApi workspace proxy", () => { Effect.gen(function* () { const req = yield* HttpServerRequest.HttpServerRequest const body = yield* req.text - return yield* HttpServerResponse.json({ path: req.url, method: req.method, body }, { - status: 201, - headers: { - "content-encoding": "identity", - "content-length": "999", - "x-remote": "yes", + return yield* HttpServerResponse.json( + { path: req.url, method: req.method, body }, + { + status: 201, + headers: { + "content-encoding": "identity", + "content-length": "999", + "x-remote": "yes", + }, }, - }) + ) }), ) const url = yield* serverUrl() From 639e27c3ce3164480ef6abbb5775285ea0f51aa4 Mon Sep 17 00:00:00 2001 From: Ruben De Smet Date: Wed, 29 Apr 2026 23:26:24 +0200 Subject: [PATCH 0012/1114] feat: add Mistral Medium 3.5 with reasoning support (#24996) --- packages/opencode/src/provider/transform.ts | 5 +++-- .../opencode/test/provider/transform.test.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a8f2fcf30857..b52c94cc0948 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -760,9 +760,10 @@ export function variants(model: Provider.Model): Record mistralId.includes(id))) return {} return { high: { reasoningEffort: "high" }, } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c4831fa82f1c..a12165b4f02c 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2257,7 +2257,7 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("mistral with reasoning returns variants", () => { + test("mistral models with reasoning support return variants", () => { const model = createMockModel({ id: "mistral/mistral-small-latest", providerID: "mistral", @@ -2274,6 +2274,23 @@ describe("ProviderTransform.variants", () => { }) }) + test("mistral-medium-3.5 with reasoning returns variants", () => { + const model = createMockModel({ + id: "mistral/mistral-medium-3.5", + providerID: "mistral", + api: { + id: "mistral-medium-3.5", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + high: { reasoningEffort: "high" }, + }) + }) + test("mistral without reasoning returns empty object", () => { const model = createMockModel({ id: "mistral/mistral-large", From 588261076adba0bc385f60fe18637da53ed03420 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:47:45 -0500 Subject: [PATCH 0013/1114] fix: make deepseek string check a bit looser (#25012) --- packages/opencode/src/provider/transform.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b52c94cc0948..cc0fcff8d318 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -197,7 +197,7 @@ function normalizeMessages( } // Deepseek requires all assistant messages to have reasoning on them - if (model.api.id.includes("deepseek")) { + if (model.api.id.toLowerCase().includes("deepseek")) { msgs = msgs.map((msg) => { if (msg.role !== "assistant") return msg if (Array.isArray(msg.content)) { @@ -573,7 +573,7 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) From a740d2c66782ef3371146cd55d70920ae9b94daf Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:49:28 -0500 Subject: [PATCH 0014/1114] fix: adjust azure defaults to closer match openai to prevent Item .. of type 'reasoning' was provided without its required following item (#25007) --- packages/opencode/src/provider/provider.ts | 9 ++++++--- packages/opencode/src/provider/transform.ts | 2 +- packages/opencode/test/provider/transform.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 48df5a4c9d0b..702435d7dabb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1466,10 +1466,13 @@ const layer: Layer.Layer< if (combined) opts.signal = combined // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + if ( + (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") && + opts.body && + opts.method === "POST" + ) { const body = JSON.parse(opts.body as string) - const isAzure = model.providerID.includes("azure") - const keepIds = isAzure && body.store === true + const keepIds = body.store === true if (!keepIds && Array.isArray(body.input)) { for (const item of body.input) { if ("id" in item) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cc0fcff8d318..d47d1fe76ca3 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -866,7 +866,7 @@ export function options(input: { } if (input.model.api.npm === "@ai-sdk/azure") { - result["store"] = true + result["store"] = false result["promptCacheKey"] = input.sessionID } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index a12165b4f02c..9b66eaa77c5d 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -101,7 +101,7 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) - test("should set store=true for azure provider by default", () => { + test("should set store=false for azure provider by default", () => { const azureModel = { ...mockModel, providerID: "azure", @@ -116,7 +116,7 @@ describe("ProviderTransform.options - setCacheKey", () => { sessionID, providerOptions: {}, }) - expect(result.store).toBe(true) + expect(result.store).toBe(false) }) }) From d7b7be1909d614a4022b345bdbeef0c1ec32e159 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:39:19 +1000 Subject: [PATCH 0015/1114] fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013) --- packages/app/src/context/global-sync.tsx | 63 +++-- .../src/context/global-sync/child-store.ts | 100 ++++--- .../app/src/context/global-sync/queue.test.ts | 46 ++++ packages/app/src/context/global-sync/queue.ts | 15 +- .../app/src/context/global-sync/utils.test.ts | 19 +- packages/app/src/context/global-sync/utils.ts | 1 + packages/app/src/pages/layout.tsx | 76 +++--- packages/app/src/pages/layout/helpers.test.ts | 16 +- packages/app/src/pages/layout/helpers.ts | 17 +- .../src/pages/layout/sidebar-workspace.tsx | 5 +- packages/app/src/utils/path-key.ts | 24 ++ packages/app/src/utils/persist.test.ts | 52 ++++ packages/app/src/utils/persist.ts | 246 +++++++++++++----- packages/opencode/test/cli/tui/thread.test.ts | 7 +- 14 files changed, 485 insertions(+), 202 deletions(-) create mode 100644 packages/app/src/context/global-sync/queue.test.ts create mode 100644 packages/app/src/utils/path-key.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2c80f31b19ba..ba9f6d52ab80 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" +import { directoryKey } from "./global-sync/utils" type GlobalStore = { ready: boolean @@ -169,18 +170,20 @@ function createGlobalSync() { const queue = createRefreshQueue({ paused, + key: directoryKey, bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrapInstance, }) const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) + const key = directoryKey(directory) + const cached = sdkCache.get(key) if (cached) return cached const sdk = globalSDK.createClient({ directory, throwOnError: true, }) - sdkCache.set(directory, sdk) + sdkCache.set(key, sdk) return sdk } @@ -192,23 +195,25 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - queue.clear(directory) - sessionMeta.delete(directory) - sdkCache.delete(directory) - clearProviderRev(directory) - clearSessionPrefetchDirectory(directory) + const key = directoryKey(directory) + queue.clear(key) + sessionMeta.delete(key) + sdkCache.delete(key) + clearProviderRev(key) + clearSessionPrefetchDirectory(key) }, translate: language.t, getSdk: sdkFor, }) async function loadSessions(directory: string) { - const pending = sessionLoads.get(directory) + const key = directoryKey(directory) + const pending = sessionLoads.get(key) if (pending) return pending - children.pin(directory) + children.pin(key) const [store, setStore] = children.child(directory, { bootstrap: false }) - const meta = sessionMeta.get(directory) + const meta = sessionMeta.get(key) if (meta && meta.limit >= store.limit) { const next = trimSessions(store.session, { limit: store.limit, @@ -218,14 +223,14 @@ function createGlobalSync() { setStore("session", reconcile(next, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) } - children.unpin(directory) + children.unpin(key) return } const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ - ...loadSessionsQuery(directory), + ...loadSessionsQuery(key), queryFn: () => loadRootSessionsWithFallback({ directory, @@ -255,7 +260,7 @@ function createGlobalSync() { setStore("session", reconcile(sessions, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) }) - sessionMeta.set(directory, { limit }) + sessionMeta.set(key, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -270,23 +275,24 @@ function createGlobalSync() { }) .then(() => {}) - sessionLoads.set(directory, promise) + sessionLoads.set(key, promise) void promise.finally(() => { - sessionLoads.delete(directory) - children.unpin(directory) + sessionLoads.delete(key) + children.unpin(key) }) return promise } async function bootstrapInstance(directory: string) { - if (!directory) return - const pending = booting.get(directory) + const key = directoryKey(directory) + if (!key) return + const pending = booting.get(key) if (pending) return pending - children.pin(directory) + children.pin(key) const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(directory) + const cache = children.vcsCache.get(key) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ @@ -307,16 +313,17 @@ function createGlobalSync() { }) }) - booting.set(directory, promise) + booting.set(key, promise) void promise.finally(() => { - booting.delete(directory) - children.unpin(directory) + booting.delete(key) + children.unpin(key) }) return promise } const unsub = globalSDK.event.listen((e) => { const directory = e.name + const key = directoryKey(directory) const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 @@ -339,9 +346,9 @@ function createGlobalSync() { return } - const existing = children.children[directory] + const existing = children.children[key] if (!existing) return - children.mark(directory) + children.mark(key) const [store, setStore] = existing applyDirectoryEvent({ event, @@ -350,9 +357,9 @@ function createGlobalSync() { setStore, push: queue.push, setSessionTodo, - vcsCache: children.vcsCache.get(directory), + vcsCache: children.vcsCache.get(key), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) + void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) }, }) }) @@ -363,7 +370,7 @@ function createGlobalSync() { }) onCleanup(() => { for (const directory of Object.keys(children.children)) { - children.disposeDirectory(directory) + children.disposeDirectory(directoryKey(directory)) } }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index d3b82894a46c..4c3c677a75c6 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQueries } from "@tanstack/solid-query" import { loadPathQuery, loadProvidersQuery } from "./bootstrap" import { loadLspQuery, loadMcpQuery } from "../global-sync" +import { directoryKey, type DirectoryKey } from "./utils" export function createChildStoreManager(input: { owner: Owner @@ -36,30 +37,37 @@ export function createChildStoreManager(input: { const ownerPins = new WeakMap>() const disposers = new Map void>() + const markKey = (key: DirectoryKey) => { + if (!key) return + lifecycle.set(key, { lastAccessAt: Date.now() }) + runEviction(key) + } + const mark = (directory: string) => { - if (!directory) return - lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction(directory) + const key = directoryKey(directory) + markKey(key) } const pin = (directory: string) => { - if (!directory) return - pins.set(directory, (pins.get(directory) ?? 0) + 1) - mark(directory) + const key = directoryKey(directory) + if (!key) return + pins.set(key, (pins.get(key) ?? 0) + 1) + markKey(key) } const unpin = (directory: string) => { - if (!directory) return - const next = (pins.get(directory) ?? 0) - 1 + const key = directoryKey(directory) + if (!key) return + const next = (pins.get(key) ?? 0) - 1 if (next > 0) { - pins.set(directory, next) + pins.set(key, next) return } - pins.delete(directory) + pins.delete(key) runEviction() } - const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0 const pinForOwner = (directory: string) => { const current = getOwner() @@ -81,30 +89,31 @@ export function createChildStoreManager(input: { }) } - function disposeDirectory(directory: string) { + function disposeDirectory(directory: DirectoryKey) { + const key = directory if ( !canDisposeDirectory({ - directory, - hasStore: !!children[directory], - pinned: pinned(directory), - booting: input.isBooting(directory), - loadingSessions: input.isLoadingSessions(directory), + directory: key, + hasStore: !!children[key], + pinned: pinned(key), + booting: input.isBooting(key), + loadingSessions: input.isLoadingSessions(key), }) ) { return false } - vcsCache.delete(directory) - metaCache.delete(directory) - iconCache.delete(directory) - lifecycle.delete(directory) - const dispose = disposers.get(directory) + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + const dispose = disposers.get(key) if (dispose) { dispose() - disposers.delete(directory) + disposers.delete(key) } - delete children[directory] - input.onDispose(directory) + delete children[key] + input.onDispose(key) return true } @@ -121,13 +130,14 @@ export function createChildStoreManager(input: { }).filter((directory) => directory !== skip) if (list.length === 0) return for (const directory of list) { - if (!disposeDirectory(directory)) continue + if (!disposeDirectory(directoryKey(directory))) continue } } function ensureChild(directory: string) { - if (!directory) console.error("No directory provided") - if (!children[directory]) { + const key = directoryKey(directory) + if (!key) console.error("No directory provided") + if (!children[key]) { const vcs = runWithOwner(input.owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -136,7 +146,7 @@ export function createChildStoreManager(input: { ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const meta = runWithOwner(input.owner, () => persisted( @@ -145,7 +155,7 @@ export function createChildStoreManager(input: { ), ) if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) - metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => persisted( @@ -154,7 +164,7 @@ export function createChildStoreManager(input: { ), ) if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) - iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => createRoot((dispose) => { @@ -165,10 +175,10 @@ export function createChildStoreManager(input: { const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(directory, sdk), - loadMcpQuery(directory, sdk), - loadLspQuery(directory, sdk), - loadProvidersQuery(directory, sdk), + loadPathQuery(key, sdk), + loadMcpQuery(key, sdk), + loadLspQuery(key, sdk), + loadProvidersQuery(key, sdk), ], })) @@ -213,13 +223,13 @@ export function createChildStoreManager(input: { message: {}, part: {}, }) - children[directory] = child - disposers.set(directory, dispose) + children[key] = child + disposers.set(key, dispose) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return void init.then(() => { - if (children[directory] !== child) return + if (children[key] !== child) return run() }) } @@ -243,15 +253,16 @@ export function createChildStoreManager(input: { runWithOwner(input.owner, init) } - mark(directory) - const childStore = children[directory] + markKey(key) + const childStore = children[key] if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } function child(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) const childStore = ensureChild(directory) - pinForOwner(directory) + pinForOwner(key) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) @@ -260,6 +271,7 @@ export function createChildStoreManager(input: { } function peek(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) const childStore = ensureChild(directory) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { @@ -269,8 +281,9 @@ export function createChildStoreManager(input: { } function projectMeta(directory: string, patch: ProjectMeta) { + const key = directoryKey(directory) const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) + const cached = metaCache.get(key) if (!cached) return const previous = store.projectMeta ?? {} const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon @@ -286,8 +299,9 @@ export function createChildStoreManager(input: { } function projectIcon(directory: string, value: string | undefined) { + const key = directoryKey(directory) const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) + const cached = iconCache.get(key) if (!cached) return if (store.icon === value) return cached.setStore("value", value) diff --git a/packages/app/src/context/global-sync/queue.test.ts b/packages/app/src/context/global-sync/queue.test.ts new file mode 100644 index 000000000000..c9919855ebe1 --- /dev/null +++ b/packages/app/src/context/global-sync/queue.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createRefreshQueue } from "./queue" +import { directoryKey } from "./utils" + +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe("createRefreshQueue", () => { + test("clears queued directories by normalized key", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + queue.clear("C:/tmp/demo") + + await tick() + + expect(calls).toEqual([]) + queue.dispose() + }) + + test("passes the original directory to bootstrapInstance", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + + await tick() + + expect(calls).toEqual(["C:\\tmp\\demo"]) + queue.dispose() + }) +}) diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index 5c228dac043f..947e31ac9080 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -2,22 +2,25 @@ type QueueInput = { paused: () => boolean bootstrap: () => Promise bootstrapInstance: (directory: string) => Promise | void + key?: (directory: string) => string } export function createRefreshQueue(input: QueueInput) { - const queued = new Set() + const queued = new Map() let root = false let running = false let timer: ReturnType | undefined + const key = input.key ?? ((directory: string) => directory) + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) const take = (count: number) => { if (queued.size === 0) return [] as string[] const items: string[] = [] - for (const item of queued) { - queued.delete(item) - items.push(item) + for (const [id, directory] of queued) { + queued.delete(id) + items.push(directory) if (items.length >= count) break } return items @@ -33,7 +36,7 @@ export function createRefreshQueue(input: QueueInput) { const push = (directory: string) => { if (!directory) return - queued.add(directory) + queued.set(key(directory), directory) if (input.paused()) return schedule() } @@ -73,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) { push, refresh, clear(directory: string) { - queued.delete(directory) + queued.delete(key(directory)) }, dispose() { if (!timer) return diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 6d44ac9a8928..406c0f124ed8 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Agent } from "@opencode-ai/sdk/v2/client" -import { normalizeAgentList } from "./utils" +import { directoryKey, normalizeAgentList } from "./utils" const agent = (name = "build") => ({ @@ -33,3 +33,20 @@ describe("normalizeAgentList", () => { expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) }) }) + +describe("directoryKey", () => { + test("normalizes slashes", () => { + expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode") + }) + + test("preserves backslashes in posix paths", () => { + expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar") + }) + + test("trims trailing slashes without breaking roots", () => { + expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/"))).toBe("C:/") + expect(String(directoryKey("/"))).toBe("/") + }) +}) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index cac58f3174e1..b982990884bf 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,4 +1,5 @@ import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d9ce87a02e90..27eae67c022c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -64,14 +64,8 @@ import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" -import { - displayName, - effectiveWorkspaceOrder, - errorMessage, - latestRootSession, - sortedRootSessions, - workspaceKey, -} from "./layout/helpers" +import { pathKey } from "@/utils/path-key" +import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -164,7 +158,7 @@ export default function Layout(props: ParentProps) { const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { - const key = workspaceKey(directory) + const key = pathKey(directory) if (value) { setState("busyWorkspaces", key, true) return @@ -176,7 +170,7 @@ export default function Layout(props: ParentProps) { }), ) } - const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] + const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)] const navLeave = { current: undefined as number | undefined } const sortNow = () => state.sortNow let sizet: number | undefined @@ -497,8 +491,8 @@ export default function Layout(props: ParentProps) { } const currentSession = params.id - if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return - if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return + if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return + if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return dismissSessionAlert(sessionKey) @@ -556,14 +550,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return - const key = workspaceKey(directory) + const key = pathKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => workspaceKey(p.worktree) === key) + const direct = projects.find((p) => pathKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -596,7 +590,7 @@ export default function Layout(props: ParentProps) { }) const workspaceName = (directory: string, projectId?: string, branch?: string) => { - const key = workspaceKey(directory) + const key = pathKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] if (direct) return direct if (!projectId) return @@ -605,7 +599,7 @@ export default function Layout(props: ParentProps) { } const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { - const key = workspaceKey(directory) + const key = pathKey(directory) setStore("workspaceName", key, next) if (!projectId) return if (!branch) return @@ -633,7 +627,7 @@ export default function Layout(props: ParentProps) { const activeDir = currentDir() return workspaceIds(project).filter((directory) => { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree - const active = workspaceKey(directory) === workspaceKey(activeDir) + const active = pathKey(directory) === pathKey(activeDir) return expanded || active }) }) @@ -644,10 +638,10 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const key = workspaceKey(directory) + const key = pathKey(directory) const project = projects.find( (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -700,7 +694,7 @@ export default function Layout(props: ParentProps) { seen: lru, keep: sessionID, limit: PREFETCH_MAX_SESSIONS_PER_DIR, - preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined, + preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined, }) } @@ -1221,17 +1215,17 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { - const key = workspaceKey(directory) + const key = pathKey(directory) const project = layout.projects .list() .find( (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), + ([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key), ) if (known) return known[0] @@ -1283,7 +1277,7 @@ export default function Layout(props: ParentProps) { : [root] const canOpen = (value: string | undefined) => { if (!value) return false - return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) + return dirs.some((item) => pathKey(item) === pathKey(value)) } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) @@ -1409,9 +1403,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const key = workspaceKey(directory) - const index = list.findIndex((x) => workspaceKey(x.worktree) === key) - const active = workspaceKey(currentProject()?.worktree ?? "") === key + const key = pathKey(directory) + const index = list.findIndex((x) => pathKey(x.worktree) === key) + const active = pathKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1485,8 +1479,8 @@ export default function Layout(props: ParentProps) { if (directory === root) return const current = currentDir() - const currentKey = workspaceKey(current) - const deletedKey = workspaceKey(directory) + const currentKey = pathKey(current) + const deletedKey = pathKey(directory) const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) if (!leaveDeletedWorkspace && shouldLeave) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1509,7 +1503,7 @@ export default function Layout(props: ParentProps) { if (!result) return - if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) { + if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) { clearLastProjectSession(root) } @@ -1529,12 +1523,12 @@ export default function Layout(props: ParentProps) { if (shouldLeave) return const nextCurrent = currentDir() - const nextKey = workspaceKey(nextCurrent) + const nextKey = pathKey(nextCurrent) const project = layout.projects.list().find((item) => item.worktree === root) const dirs = project ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) : [root] - const valid = dirs.some((item) => workspaceKey(item) === nextKey) + const valid = dirs.some((item) => pathKey(item) === nextKey) if (params.dir && projectRoot(nextCurrent) === root && !valid) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1640,7 +1634,7 @@ export default function Layout(props: ParentProps) { }) const handleDelete = () => { - const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory) + const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory) if (leaveDeletedWorkspace) { navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) } @@ -1867,11 +1861,11 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const extra = directory && - workspaceKey(directory) !== workspaceKey(local) && - !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + pathKey(directory) !== pathKey(local) && + !dirs.some((item) => pathKey(item) === pathKey(directory)) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1916,7 +1910,7 @@ export default function Layout(props: ParentProps) { setStore( "workspaceOrder", project.worktree, - result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)), + result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)), ) } @@ -1942,8 +1936,8 @@ export default function Layout(props: ParentProps) { setWorkspaceName(created.directory, created.branch, project.id, created.branch) const local = project.worktree - const key = workspaceKey(created.directory) - const root = workspaceKey(local) + const key = pathKey(created.directory) + const root = pathKey(local) setBusy(created.directory, true) WorktreeState.pending(created.directory) @@ -1954,7 +1948,7 @@ export default function Layout(props: ParentProps) { setStore("workspaceOrder", project.worktree, (prev) => { const existing = prev ?? [] const next = existing.filter((item) => { - const id = workspaceKey(item) + const id = pathKey(item) return id !== root && id !== key }) return [created.directory, ...next] diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 988332ab7ce1..9cf302482b76 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -14,8 +14,8 @@ import { errorMessage, hasProjectPermissions, latestRootSession, - workspaceKey, } from "./helpers" +import { pathKey } from "@/utils/path-key" const session = (input: Partial & Pick) => ({ @@ -104,16 +104,16 @@ describe("layout deep links", () => { describe("layout workspace helpers", () => { test("normalizes trailing slash in workspace key", () => { - expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") + expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo") + expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo") }) test("preserves posix and drive roots in workspace key", () => { - expect(workspaceKey("/")).toBe("/") - expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:/") - expect(workspaceKey("C://")).toBe("C:/") - expect(workspaceKey("C:///")).toBe("C:/") + expect(String(pathKey("/"))).toBe("/") + expect(String(pathKey("///"))).toBe("/") + expect(String(pathKey("C:\\"))).toBe("C:/") + expect(String(pathKey("C://"))).toBe("C:/") + expect(String(pathKey("C:///"))).toBe("C:/") }) test("keeps local first while preserving known order", () => { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 4bc5254d959d..d53381e40462 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,19 +1,12 @@ import { getFilename } from "@opencode-ai/core/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +import { pathKey } from "@/utils/path-key" type SessionStore = { session?: Session[] path: { directory: string } } -export const workspaceKey = (directory: string) => { - const value = directory.replaceAll("\\", "/") - const drive = value.match(/^([A-Za-z]:)\/+$/) - if (drive) return `${drive[1]}/` - if (/^\/+$/i.test(value)) return "/" - return value.replace(/\/+$/, "") -} - function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { @@ -29,7 +22,7 @@ function sortSessions(now: number) { } const isRootVisibleSession = (session: Session, directory: string) => - workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived export const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) @@ -72,11 +65,11 @@ export const errorMessage = (err: unknown, fallback: string) => { } export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { - const root = workspaceKey(local) + const root = pathKey(local) const live = new Map() for (const dir of dirs) { - const key = workspaceKey(dir) + const key = pathKey(dir) if (key === root) continue if (!live.has(key)) live.set(key, dir) } @@ -85,7 +78,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted const result = [local] for (const dir of persisted) { - const key = workspaceKey(dir) + const key = pathKey(dir) if (key === root) continue const match = live.get(key) if (!match) continue diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 0a3fc7f41d50..d2e887b4444f 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -16,8 +16,9 @@ import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { pathKey } from "@/utils/path-key" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { sortedRootSessions, workspaceKey } from "./helpers" +import { sortedRootSessions } from "./helpers" import { useQuery } from "@tanstack/solid-query" type InlineEditorComponent = (props: { @@ -309,7 +310,7 @@ export const SortableWorkspace = (props: { const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) + const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory)) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts new file mode 100644 index 000000000000..68d53e91d863 --- /dev/null +++ b/packages/app/src/utils/path-key.ts @@ -0,0 +1,24 @@ +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const trimTrailingSlashes = (value: string) => { + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] !== "/") return value.slice(0, i + 1) + } + return "" +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + const trimmed = trimTrailingSlashes(value) + if (!trimmed && value.startsWith("/")) return "/" as PathKey + if (isDrive(trimmed)) return `${trimmed}/` as PathKey + return trimmed as PathKey +} diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 673acd224d24..12e970eea0d8 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -1,6 +1,8 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" type PersistTestingType = typeof import("./persist").PersistTesting +type PersistType = typeof import("./persist").Persist +type RemovePersistedType = typeof import("./persist").removePersisted class MemoryStorage implements Storage { private values = new Map() @@ -45,6 +47,8 @@ class MemoryStorage implements Storage { const storage = new MemoryStorage() let persistTesting: PersistTestingType +let Persist: PersistType +let removePersisted: RemovePersistedType beforeAll(async () => { mock.module("@/context/platform", () => ({ @@ -53,6 +57,8 @@ beforeAll(async () => { const mod = await import("./persist") persistTesting = mod.PersistTesting + Persist = mod.Persist + removePersisted = mod.removePersisted }) beforeEach(() => { @@ -112,4 +118,50 @@ describe("persist localStorage resilience", () => { expect(result.endsWith(".dat")).toBeTrue() expect(/[:\\/]/.test(result)).toBeFalse() }) + + test("workspace target keeps raw path storage as legacy fallback", () => { + const target = Persist.workspace("C:\\Users\\foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => { + const target = Persist.workspace("C:/Users/foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("migrates direct legacy keys into scoped storage", () => { + storage.setItem("legacy.workspace", '{"value":2}') + const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"]) + const current = persistTesting.localStorageWithPrefix(target.storage!) + const legacyStore = persistTesting.localStorageDirect() + + const result = persistTesting.migrateLegacy({ + current, + legacyStore, + stores: [], + keys: target.legacy!, + key: target.key, + defaults: { value: 1 }, + }) + + expect(result).toBe('{"value":2}') + expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}') + expect(legacyStore.getItem("legacy.workspace")).toBeNull() + expect(storage.getItem("legacy.workspace")).toBeNull() + }) + + test("removes legacy workspace storage when removing persisted target", () => { + const target = Persist.workspace("C:\\Users\\foo", "terminal") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 024552727439..8f3e080738eb 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -3,6 +3,7 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi import { checksum } from "@opencode-ai/core/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" +import { pathKey } from "@/utils/path-key" type InitType = Promise | string | null type PersistedWithReady = [ @@ -14,6 +15,7 @@ type PersistedWithReady = [ type PersistTarget = { storage?: string + legacyStorageNames?: string[] key: string legacy?: string[] migrate?: (value: unknown) => unknown @@ -208,12 +210,153 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => return JSON.stringify(merged) } +function readCurrent(input: { + storage: SyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.storage.removeItem(input.key) + return null + } + if (raw !== next) input.storage.setItem(input.key, next) + return next +} + +function migrateLegacy(input: { + current: SyncStorage + legacyStore?: SyncStorage + stores: SyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + store.removeItem(input.key) + continue + } + input.current.setItem(input.key, next) + store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.legacyStore.removeItem(key) + continue + } + input.current.setItem(input.key, next) + input.legacyStore.removeItem(key) + return next + } + + return null +} + +async function readCurrentAsync(input: { + storage: AsyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = await input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await input.storage.removeItem(input.key).catch(() => undefined) + return null + } + if (raw !== next) await input.storage.setItem(input.key, next) + return next +} + +async function removeAsync(storage: AsyncStorage, key: string) { + try { + await storage.removeItem(key) + } catch {} +} + +async function migrateLegacyAsync(input: { + current: AsyncStorage + legacyStore?: AsyncStorage + stores: AsyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = await store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(store, input.key) + continue + } + await input.current.setItem(input.key, next) + await store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = await input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(input.legacyStore, key) + continue + } + await input.current.setItem(input.key, next) + await input.legacyStore.removeItem(key) + return next + } + + return null +} + function workspaceStorage(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const sum = checksum(dir) ?? "0" return `opencode.workspace.${head}.${sum}.dat` } +function legacyWorkspaceStorage(dir: string) { + const storage = workspaceStorage(pathKey(dir)) + const result = new Set() + const raw = workspaceStorage(dir) + if (raw !== storage) result.add(raw) + + const key = pathKey(dir) + const drive = key.length >= 3 && key[1] === ":" && key[2] === "/" + if (drive) { + const backslash = workspaceStorage(key.replaceAll("/", "\\")) + if (backslash !== storage) result.add(backslash) + } + + if (result.size === 0) return + return [...result] +} + function localStorageWithPrefix(prefix: string): SyncStorage { const base = `${prefix}:` const scope = `prefix:${prefix}` @@ -304,6 +447,7 @@ function localStorageDirect(): SyncStorage { export const PersistTesting = { localStorageDirect, localStorageWithPrefix, + migrateLegacy, normalize, workspaceStorage, } @@ -313,10 +457,17 @@ export const Persist = { return { storage: GLOBAL_STORAGE, key, legacy } }, workspace(dir: string, key: string, legacy?: string[]): PersistTarget { - return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy } + const storage = workspaceStorage(pathKey(dir)) + return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy } }, session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy } + const storage = workspaceStorage(pathKey(dir)) + return { + storage, + legacyStorageNames: legacyWorkspaceStorage(dir), + key: `session:${session}:${key}`, + legacy, + } }, scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { if (session) return Persist.session(dir, session, key, legacy) @@ -324,11 +475,15 @@ export const Persist = { }, } -export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) { +export function removePersisted(target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform) { const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { - return platform.storage?.(target.storage)?.removeItem(target.key) + void platform.storage?.(target.storage)?.removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + void platform.storage?.(storage)?.removeItem(target.key) + } + return } if (!target.storage) { @@ -337,6 +492,9 @@ export function removePersisted(target: { storage?: string; key: string }, platf } localStorageWithPrefix(target.storage).removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + localStorageWithPrefix(storage).removeItem(target.key) + } } export function persisted( @@ -363,39 +521,27 @@ export function persisted( return platform.storage?.(LEGACY_STORAGE) })() + const legacyStorageNames = config.legacyStorageNames ?? [] + const storage = (() => { if (!isDesktop) { const current = currentStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage + const legacyStores = legacyStorageNames.map(localStorageWithPrefix) const api: SyncStorage = { getItem: (key) => { - const raw = current.getItem(key) - if (raw !== null) { - const next = normalize(defaults, raw, config.migrate) - if (next === undefined) { - current.removeItem(key) - return null - } - if (raw !== next) current.setItem(key, next) - return next - } - - for (const legacyKey of legacy) { - const legacyRaw = legacyStore.getItem(legacyKey) - if (legacyRaw === null) continue - - const next = normalize(defaults, legacyRaw, config.migrate) - if (next === undefined) { - legacyStore.removeItem(legacyKey) - continue - } - current.setItem(key, next) - legacyStore.removeItem(legacyKey) - return next - } - - return null + const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + return migrateLegacy({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) }, setItem: (key, value) => { current.setItem(key, value) @@ -410,37 +556,21 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined + const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x) const api: AsyncStorage = { getItem: async (key) => { - const raw = await current.getItem(key) - if (raw !== null) { - const next = normalize(defaults, raw, config.migrate) - if (next === undefined) { - await current.removeItem(key).catch(() => undefined) - return null - } - if (raw !== next) await current.setItem(key, next) - return next - } - - if (!legacyStore) return null - - for (const legacyKey of legacy) { - const legacyRaw = await legacyStore.getItem(legacyKey) - if (legacyRaw === null) continue - - const next = normalize(defaults, legacyRaw, config.migrate) - if (next === undefined) { - await legacyStore.removeItem(legacyKey).catch(() => undefined) - continue - } - await current.setItem(key, next) - await legacyStore.removeItem(legacyKey) - return next - } - - return null + const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + return migrateLegacyAsync({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) }, setItem: async (key, value) => { await current.setItem(key, value) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index bca0d87507ad..e2bd9d7bcccf 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -71,11 +71,11 @@ describe("tui thread", () => { async function check(project?: string) { setup() - await using tmp = await tmpdir({ git: true }) const cwd = process.cwd() const pwd = process.env.PWD const worker = globalThis.Worker const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" seen.tui.length = 0 @@ -109,11 +109,12 @@ describe("tui thread", () => { } } - test("uses the real cwd when PWD points at a symlink", async () => { + // serial because both modify real env vars + test.serial("uses the real cwd when PWD points at a symlink", async () => { await check() }) - test("uses the real cwd after resolving a relative project from PWD", async () => { + test.serial("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) }) From 12cbfe5b640d41310cc62dc6aaa3e8bc112a7a1f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Apr 2026 22:40:26 +0000 Subject: [PATCH 0016/1114] chore: generate --- packages/app/src/pages/layout.tsx | 22 +++++++++++----------- packages/app/src/utils/persist.ts | 9 +++++++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 27eae67c022c..7e9e2d32aaba 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -65,7 +65,13 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { pathKey } from "@/utils/path-key" -import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers" +import { + displayName, + effectiveWorkspaceOrder, + errorMessage, + latestRootSession, + sortedRootSessions, +} from "./layout/helpers" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -640,8 +646,7 @@ export default function Layout(props: ParentProps) { if (!expanded) continue const key = pathKey(directory) const project = projects.find( - (item) => - pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -1218,10 +1223,7 @@ export default function Layout(props: ParentProps) { const key = pathKey(directory) const project = layout.projects .list() - .find( - (item) => - pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), - ) + .find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key)) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( @@ -1634,7 +1636,7 @@ export default function Layout(props: ParentProps) { }) const handleDelete = () => { - const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory) + const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory) if (leaveDeletedWorkspace) { navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) } @@ -1863,9 +1865,7 @@ export default function Layout(props: ParentProps) { const active = currentProject() const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const extra = - directory && - pathKey(directory) !== pathKey(local) && - !dirs.some((item) => pathKey(item) === pathKey(directory)) + directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory)) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 8f3e080738eb..627f3a5a1a77 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -475,7 +475,10 @@ export const Persist = { }, } -export function removePersisted(target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform) { +export function removePersisted( + target: { storage?: string; legacyStorageNames?: string[]; key: string }, + platform?: Platform, +) { const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { @@ -556,7 +559,9 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined - const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x) + const legacyStores = legacyStorageNames + .map((name) => platform.storage?.(name) as AsyncStorage | undefined) + .filter((x) => !!x) const api: AsyncStorage = { getItem: async (key) => { From ea89925042e4fa4ca7fb88684361d1c99c862c5a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:32:26 +1000 Subject: [PATCH 0017/1114] fix: handle invalid mcp urls (#25019) --- packages/opencode/src/mcp/index.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 0a3137c0ecf0..fe7180238851 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -114,6 +114,11 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info { const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") +function remoteURL(key: string, value: string) { + if (URL.canParse(value)) return new URL(value) + log.warn("invalid remote mcp url", { key }) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -267,6 +272,13 @@ export const layer = Layer.effect( ) { const oauthDisabled = mcp.oauth === false const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined + const url = remoteURL(key, mcp.url) + if (!url) { + return { + client: undefined as MCPClient | undefined, + status: { status: "failed" as const, error: `Invalid MCP URL for "${key}"` }, + } + } let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { @@ -291,14 +303,14 @@ export const layer = Layer.effect( const transports: Array<{ name: string; transport: TransportWithAuth }> = [ { name: "StreamableHTTP", - transport: new StreamableHTTPClientTransport(new URL(mcp.url), { + transport: new StreamableHTTPClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, }), }, { name: "SSE", - transport: new SSEClientTransport(new URL(mcp.url), { + transport: new SSEClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, }), @@ -722,6 +734,8 @@ export const layer = Layer.effect( if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`) if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) + const url = remoteURL(mcpName, mcpConfig.url) + if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`) // OAuth config is optional - if not provided, we'll use auto-discovery const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined @@ -751,7 +765,7 @@ export const layer = Layer.effect( auth, ) - const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider }) + const transport = new StreamableHTTPClientTransport(url, { authProvider }) return yield* Effect.tryPromise({ try: () => { From ac6aa43e3b75343a3268c1ce42cad56c309e58cb Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 29 Apr 2026 23:33:39 +0000 Subject: [PATCH 0018/1114] sync release versions for v1.14.30 --- 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 c0337c4a6102..01d63a24ab89 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -83,7 +83,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +117,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +144,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.29", + "version": "1.14.30", "bin": { "opencode": "./bin/opencode", }, @@ -226,7 +226,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -259,7 +259,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -303,7 +303,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -332,7 +332,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -348,7 +348,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.29", + "version": "1.14.30", "bin": { "opencode": "./bin/opencode", }, @@ -491,7 +491,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -526,7 +526,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "cross-spawn": "catalog:", }, @@ -541,7 +541,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -576,7 +576,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.29", + "version": "1.14.30", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -625,7 +625,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.29", + "version": "1.14.30", "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 31f8767b5471..1a5a1a007fe5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.29", + "version": "1.14.30", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 698c39c207a8..49b4cf48cf77 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.29", + "version": "1.14.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 8a0443b9f592..3e1bd07f35ff 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.29", + "version": "1.14.30", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index dda8212e074c..5e450446ae68 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.29", + "version": "1.14.30", "$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 f1988a02eaa6..4839bdc7eeda 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.29", + "version": "1.14.30", "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 9a1744c569f9..428419434e26 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.29", + "version": "1.14.30", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 80eaaa0f1a52..25861595140e 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.29", + "version": "1.14.30", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f8392270bb36..ff81acaaf34b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.29", + "version": "1.14.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 1431e13728e1..98a5ddd83f64 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.29", + "version": "1.14.30", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index a64c3005254f..80c73f876b27 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.29" +version = "1.14.30" 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.29/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/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.29/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/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.29/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/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.29/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index da6498cdfbd7..bce26ed57f77 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.29", + "version": "1.14.30", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8d4e53ee4dc0..425ddea77acb 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.29", + "version": "1.14.30", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a8517522c475..ad580a187c85 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.29", + "version": "1.14.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 515b693a5653..1fbf05d5e1c1 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.29", + "version": "1.14.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index db534d36153c..b19077be0409 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.29", + "version": "1.14.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3f8c02b3a0c0..641f0667ca5d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.29", + "version": "1.14.30", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 199d315d2132..c487d6ba424e 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.29", + "version": "1.14.30", "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 b3adfa26947d..2d3783417309 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.29", + "version": "1.14.30", "publisher": "sst-dev", "repository": { "type": "git", From 61dfae31e7a9a2a1c749d44a0afe9759a0131cff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 19:37:50 -0400 Subject: [PATCH 0019/1114] test: cover HttpApi websocket proxy (#25017) --- .../test/server/workspace-proxy.test.ts | 83 ++++++++++++++++--- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 549e700d1e84..a39a33b8c61a 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -1,26 +1,66 @@ import { NodeHttpServer } from "@effect/platform-node" import Http from "node:http" import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Context, Effect, Layer, Queue } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" import { testEffect } from "../lib/effect" function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + Socket.layerWebSocketConstructorGlobal, +) +const it = testEffect(testServerLayer) + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +function listenServer(handler: TestHandler) { return Effect.gen(function* () { - return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler)) + return yield* serverUrl() }) } -const testServerLayer = NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }) -const it = testEffect(testServerLayer) +function listenTestServer(handler: TestHandler) { + return Effect.gen(function* () { + // Build into the current test scope so the listener stays alive until the + // test finishes. Using Effect.provide here would release it immediately. + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) +} + +function echoWebSocket(request: HttpServerRequest.HttpServerRequest) { + return Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + // The upstream announces the negotiated protocol, then echoes every + // received frame. The assertions use those messages to prove proxy flow. + yield* socket + .runRaw((message) => write(`echo:${message}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) +} describe("HttpApi workspace proxy", () => { it.live("proxies HTTP request and returns streamed response with status and headers", () => Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest + const url = yield* listenServer( + Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) { const body = yield* req.text return yield* HttpServerResponse.json( { path: req.url, method: req.method, body }, @@ -35,7 +75,6 @@ describe("HttpApi workspace proxy", () => { ) }), ) - const url = yield* serverUrl() const request = HttpServerRequest.fromWeb( new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), @@ -67,14 +106,12 @@ describe("HttpApi workspace proxy", () => { it.live("strips opencode-internal headers and merges extra headers", () => Effect.gen(function* () { let forwarded: Record = {} - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest + const url = yield* listenServer((req) => + Effect.sync(() => { forwarded = req.headers return HttpServerResponse.empty() }), ) - const url = yield* serverUrl() const request = HttpServerRequest.fromWeb( new Request("http://localhost/test", { @@ -93,4 +130,26 @@ describe("HttpApi workspace proxy", () => { expect(forwarded["x-injected"]).toBe("extra") }), ) + + it.live("proxies websocket messages and protocols", () => + Effect.gen(function* () { + const upstreamUrl = yield* listenTestServer(echoWebSocket) + + // Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener. + // The client never connects to upstream directly. + const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`)) + + const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, { + closeCodeIsError: () => false, + protocols: "chat", + }) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) }) From 6f508d574eca3d3133f4fd1945556673a3dbba96 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 20:19:52 -0400 Subject: [PATCH 0020/1114] test: deflake runner cancel test (#25021) --- packages/opencode/test/effect/runner.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index ee99050a8c83..80870a234e22 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -115,8 +115,16 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, void 0) + return yield* Effect.never.pipe(Effect.as("never")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started) expect(runner.busy).toBe(true) expect(runner.state._tag).toBe("Running") From de78dedceb44d6487ba84c446e1b713d8a7bad3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:58:42 +0000 Subject: [PATCH 0021/1114] Update VOUCHED list https://github.com/anomalyco/opencode/issues/23890#issuecomment-4348703527 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 57e03ebb9a6a..8618701ebf83 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -16,6 +16,7 @@ ariane-emory -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 From 9052e8a1bac3a546c3bd06eb2f550fa8cea3b4fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 21:08:03 -0400 Subject: [PATCH 0022/1114] test: cover HttpApi workspace routing middleware (#25027) --- packages/opencode/src/effect/service-use.ts | 38 ++ packages/opencode/src/project/project.ts | 5 +- .../server/httpapi-workspace-routing.test.ts | 437 ++++++++++++++++++ .../test/server/workspace-proxy.test.ts | 2 +- 4 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/effect/service-use.ts create mode 100644 packages/opencode/test/server/httpapi-workspace-routing.test.ts diff --git a/packages/opencode/src/effect/service-use.ts b/packages/opencode/src/effect/service-use.ts new file mode 100644 index 000000000000..a93cdecbb157 --- /dev/null +++ b/packages/opencode/src/effect/service-use.ts @@ -0,0 +1,38 @@ +import { Context, Effect } from "effect" + +type EffectMethod = (...args: ReadonlyArray) => Effect.Effect + +type ServiceUse = { + readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends ( + ...args: infer Args + ) => infer Return + ? Args extends ReadonlyArray + ? Return extends Effect.Effect + ? (...args: Args) => Effect.Effect + : never + : never + : never +} + +export const serviceUse = (tag: Context.Service) => { + // This is the only dynamic boundary: TypeScript knows the accessor shape, + // but Proxy property names are runtime values. + const access = new Proxy( + {}, + { + get: (_, key) => { + if (typeof key !== "string") return undefined + return (...args: unknown[]) => + tag.use((service) => { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. + const method = service[key as keyof Shape] + if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. + return (method as (...args: unknown[]) => Effect.Effect)(...args) + }) + }, + }, + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily. + return access as ServiceUse +} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 4229112a838b..86208a60cd2e 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" +import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) @@ -178,7 +179,7 @@ export const layer: Layer.Layer< const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe( Effect.map((x) => x.trim()), - Effect.map(ProjectID.make), + Effect.map((x) => ProjectID.make(x)), Effect.catch(() => Effect.void), ) }) @@ -485,6 +486,8 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const use = serviceUse(Service) + export function list() { return Database.use((db) => db diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts new file mode 100644 index 000000000000..6d0649922454 --- /dev/null +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -0,0 +1,437 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +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 { WorkspaceID } from "../../src/control-plane/schema" +import type { WorkspaceAdaptor } 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" +import { + WorkspaceRouteContext, + workspaceRouterMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { Database } from "../../src/storage/db" +import { resetDatabase } from "../fixture/db" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await resetDatabase() + }), + ) + }), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + Project.defaultLayer, + Socket.layerWebSocketConstructorGlobal, + ), +) + +type ProxiedRequest = { + url: string + method: string + headers: Record +} + +type TestHandler = ( + request: HttpServerRequest.HttpServerRequest, +) => Effect.Effect + +const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), +) + +const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost") + +const listenAdditionalServer = (handler: TestHandler) => + Effect.gen(function* () { + const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler)) + return HttpServer.formatAddress(server.address) + }) + +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({ + name: "Remote Test", + description: "Create a remote test workspace", + configure: (info) => ({ ...info, name: "remote-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "remote" as const, url, headers }), +}) + +const eventStreamResponse = () => + HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', { + contentType: "text/event-stream", + }) + +const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { + const url = requestURL(request) + if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse()) + if (url.pathname === "/base/sync/history") return HttpServerResponse.json([]) + return undefined +} + +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => + Effect.acquireRelease( + Effect.promise(async () => { + registerAdaptor(input.projectID, input.type, input.adaptor) + return Workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), + ) + +const createRemoteWorkspace = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string + headers?: HeadersInit +}) => + // Workspace.create starts the remote sync loop. The test upstream exposes + // /global/event and /sync/history so middleware proxying sees the remote + // workspace as active, just like production would. + createWorkspace({ + projectID: input.projectID, + type: input.type, + adaptor: remoteAdaptor(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), + }) + +const insertRemoteWorkspaceWithoutSync = (input: { + dir: string + projectID: Project.Info["id"] + type: string + url: string +}) => + Effect.sync(() => { + const id = WorkspaceID.ascending() + registerAdaptor(input.projectID, input.type, remoteAdaptor(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 + }) + +const startRemoteWorkspaceHttpServer = ( + handler: (request: ProxiedRequest) => Effect.Effect, +) => + listenAdditionalServer((request) => + Effect.gen(function* () { + // Remote workspaces run a sync loop against their target server. These + // bootstrap routes make Workspace.isSyncing(...) true for proxy tests; + // everything else is the request being proxied by the middleware. + const sync = syncResponse(request) + if (sync) return yield* sync + return yield* handler({ url: request.url, method: request.method, headers: request.headers }) + }), + ) + +const listenRemoteWebSocket = () => + listenAdditionalServer((request) => { + const sync = syncResponse(request) + if (sync) return sync + if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 })) + return echoWebSocket(request) + }) + +const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + const socket = yield* Effect.orDie(request.upgrade) + const write = yield* socket.writer + yield* socket + .runRaw((message) => write(`echo:${String(message)}`), { + onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( + Effect.catch(() => Effect.void), + ), + }) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + }) + +const serveRouteContextProbe = HttpRouter.add( + "GET", + "/probe", + Effect.gen(function* () { + // The fake route exposes the context installed by the middleware, so tests + // can assert routing decisions without pulling in the production API tree. + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), +).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + +describe("HttpApi workspace routing middleware", () => { + it.live("proxies remote workspace HTTP requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let forwarded: ProxiedRequest | undefined + + // This starts a second HTTP server that stands in for the opencode server + // backing a remote workspace. The client below still calls the local test + // server; only the middleware should call this server. + const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { + forwarded = request + const url = requestURL(request) + return HttpServerResponse.json( + { + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), + }, + { status: 201, headers: { "x-remote": "yes" } }, + ) + }) + // The adaptor 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({ + dir, + projectID: project.project.id, + type: "remote-http-target", + url: `${remoteUrl}/base`, + headers: { "x-target-auth": "secret" }, + }) + + // The local /probe handler should not run. Selecting a remote workspace + // should make the middleware call HttpApiProxy.http instead. + yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( + HttpClientRequest.setHeaders({ + "content-type": "application/json", + "x-opencode-directory": "/secret/path", + "x-opencode-workspace": "internal", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(201) + expect(response.headers["x-remote"]).toBe("yes") + expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null }) + const forwardedURL = forwarded ? requestURL(forwarded) : undefined + // These assertions are the routing contract: append the original path to + // the remote base URL, preserve normal query params, and remove workspace. + expect(forwardedURL?.pathname).toBe("/base/probe") + expect(forwardedURL?.searchParams.get("keep")).toBe("yes") + expect(forwardedURL?.searchParams.get("workspace")).toBeNull() + expect(forwarded?.method).toBe("PATCH") + expect(forwarded?.headers["content-type"]).toBe("application/json") + expect(forwarded?.headers["x-target-auth"]).toBe("secret") + expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined() + expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined() + }), + ) + + it.live("returns 503 when a remote workspace is not actively syncing", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = yield* insertRemoteWorkspaceWithoutSync({ + dir, + projectID: project.project.id, + type: "remote-not-syncing", + url: "http://127.0.0.1:1/base", + }) + + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(503) + expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`) + }), + ) + + it.live("proxies remote workspace WebSocket requests through the selected workspace target", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const remoteUrl = yield* listenRemoteWebSocket() + const workspace = yield* createRemoteWorkspace({ + dir, + projectID: project.project.id, + type: "remote-websocket-target", + url: `${remoteUrl}/base`, + }) + + // The client connects to the local test server. The middleware should + // detect the WebSocket upgrade and proxy it to the remote /base/probe. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`, + { + closeCodeIsError: () => false, + protocols: "chat", + }, + ) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + + expect(yield* Queue.take(messages)).toBe("protocol:chat") + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("echo:hello") + }), + ) + + it.live("returns a missing workspace response for unknown workspace ids", () => + Effect.gen(function* () { + const workspaceID = WorkspaceID.ascending("wrk_missing") + // If the middleware resolves the workspace first, this handler is never + // reached and the response should be the middleware error response. + yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) + + expect(response.status).toBe(500) + expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`) + }), + ) + + it.live("keeps control-plane routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "control-plane-target", + directory: workspaceDir, + }) + + // GET /session is a control-plane route: it lists sessions for the main + // process and should not be redirected into the selected workspace target. + yield* HttpRouter.add( + "GET", + "/session", + Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), + ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + + it.live("uses directory query/header fallback when no workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const queryDir = path.join(dir, "query-target") + const headerDir = path.join(dir, "header-target") + yield* serveRouteContextProbe + + // Without a selected workspace, the middleware falls back to request + // directory hints before using the process cwd. + const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`) + const headerResponse = yield* HttpClientRequest.get("/probe").pipe( + HttpClientRequest.setHeader("x-opencode-directory", headerDir), + HttpClient.execute, + ) + + expect(queryResponse.status).toBe(200) + expect(yield* queryResponse.json).toEqual({ directory: queryDir }) + expect(headerResponse.status).toBe(200) + expect(yield* headerResponse.json).toEqual({ directory: headerDir }) + }), + ) + + it.live("routes local workspace requests through WorkspaceRouteContext", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "local-target", + directory: workspaceDir, + }) + + yield* serveRouteContextProbe + + // /probe is not a control-plane route, so selecting a local workspace + // should swap the route context to the workspace target directory. + const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) +}) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index a39a33b8c61a..3e52ade6380e 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -46,7 +46,7 @@ function echoWebSocket(request: HttpServerRequest.HttpServerRequest) { // The upstream announces the negotiated protocol, then echoes every // received frame. The assertions use those messages to prove proxy flow. yield* socket - .runRaw((message) => write(`echo:${message}`), { + .runRaw((message) => write(`echo:${String(message)}`), { onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( Effect.catch(() => Effect.void), ), From 4fe14abb8cd34b8af20d31b656715fd140fa4eed Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 21:24:45 -0400 Subject: [PATCH 0023/1114] test: cover HttpApi instance context middleware (#25032) --- .opencode/skills/effect/SKILL.md | 8 + packages/opencode/test/server/AGENTS.md | 15 ++ .../server/httpapi-instance-context.test.ts | 167 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 packages/opencode/test/server/AGENTS.md create mode 100644 packages/opencode/test/server/httpapi-instance-context.test.ts diff --git a/.opencode/skills/effect/SKILL.md b/.opencode/skills/effect/SKILL.md index 78216ab01c3e..3a44fa88dcdd 100644 --- a/.opencode/skills/effect/SKILL.md +++ b/.opencode/skills/effect/SKILL.md @@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 - In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. - Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. - Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. + +## Testing Patterns + +- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations. +- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior. +- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root. +- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file. +- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state. diff --git a/packages/opencode/test/server/AGENTS.md b/packages/opencode/test/server/AGENTS.md new file mode 100644 index 000000000000..bed2b526952c --- /dev/null +++ b/packages/opencode/test/server/AGENTS.md @@ -0,0 +1,15 @@ +# Server Test Guide + +Use these patterns for server and HttpApi middleware tests in this directory. + +- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy. +- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it. +- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. +- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`. +- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits. +- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific. +- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant. +- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers. +- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests. +- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state. +- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server. diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts new file mode 100644 index 000000000000..74b1ecdeba81 --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -0,0 +1,167 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" +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 { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" +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 { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await Instance.disposeAll() + await resetDatabase() + }), + ) + }), +) + +const it = testEffect( + Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.acquireRelease( + Effect.promise(async () => { + registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + return Workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }), + (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), + ) + +const probeInstanceContext = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return yield* HttpServerResponse.json({ + directory: instance?.directory, + worktree: instance?.worktree, + projectID: instance?.project.id, + workspaceID, + }) +}) + +const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => + HttpRouter.add("GET", probePath, probeInstanceContext).pipe( + Layer.provide(instanceContextTestLayer), + HttpRouter.serve, + Layer.build, + ) + +describe("HttpApi instance context middleware", () => { + it.live("provides instance context from the routed directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + directory: dir, + worktree: dir, + projectID: project.project.id, + }) + }), + ) + + it.live("falls back to the raw directory when URI decoding fails", () => + Effect.gen(function* () { + yield* serveProbe() + + const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A") + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: path.join(process.cwd(), "%E0%A4%A"), + }) + }), + ) + + it.live("provides selected workspace id on control-plane routes", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe("/session") + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: workspace.id, + }) + }), + ) + + it.live("uses workspace routing output instead of raw directory hints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-routing-output", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: workspaceDir, + workspaceID: workspace.id, + }) + }), + ) +}) From 38adc13295471de6a8c84bc73d2c94b8e294905e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 21:34:52 -0400 Subject: [PATCH 0024/1114] test: cover HttpApi authorization middleware (#25033) --- .../test/server/httpapi-authorization.test.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/opencode/test/server/httpapi-authorization.test.ts diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts new file mode 100644 index 000000000000..7dec8991643d --- /dev/null +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -0,0 +1,137 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { testEffect } from "../lib/effect" + +const Api = HttpApi.make("test-authorization").add( + HttpApiGroup.make("test") + .add( + HttpApiEndpoint.get("probe", "/probe", { + success: Schema.String, + }), + ) + .middleware(Authorization), +) + +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) + +const apiLayer = HttpRouter.serve( + HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), + { disableListenLog: true, disableLogger: true }, +).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + } + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + }), + ) + }), +) + +const it = testEffect(apiLayer.pipe(Layer.provideMerge(testStateLayer))) + +const basic = (username: string, password: string) => + `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + +const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") + +const useAuth = (input: { password: string; username?: string }) => + Effect.acquireRelease( + Effect.sync(() => { + const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + } + Flag.OPENCODE_SERVER_PASSWORD = input.password + Flag.OPENCODE_SERVER_USERNAME = input.username + return original + }), + (original) => + Effect.sync(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + }), + ) + +const getProbe = (headers?: Record) => + HttpClientRequest.get("/probe").pipe( + headers ? HttpClientRequest.setHeaders(headers) : (request) => request, + HttpClient.execute, + ) + +describe("HttpApi authorization middleware", () => { + it.live("allows requests when server password is not configured", () => + Effect.gen(function* () { + const response = yield* getProbe() + + expect(response.status).toBe(200) + expect(yield* response.json).toBe("ok") + }), + ) + + it.live("requires configured password for basic auth", () => + Effect.gen(function* () { + yield* useAuth({ password: "secret" }) + + const [missing, badPassword, good] = yield* Effect.all( + [ + getProbe(), + getProbe({ authorization: basic("opencode", "wrong") }), + getProbe({ authorization: basic("opencode", "secret") }), + ], + { concurrency: "unbounded" }, + ) + + expect(missing.status).toBe(401) + expect(badPassword.status).toBe(401) + expect(good.status).toBe(200) + }), + ) + + it.live("respects configured basic auth username", () => + Effect.gen(function* () { + yield* useAuth({ username: "kit", password: "secret" }) + + const [defaultUser, configuredUser] = yield* Effect.all( + [getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })], + { concurrency: "unbounded" }, + ) + + expect(defaultUser.status).toBe(401) + expect(configuredUser.status).toBe(200) + }), + ) + + it.live("accepts auth token query credentials", () => + Effect.gen(function* () { + yield* useAuth({ password: "secret" }) + + const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(200) + }), + ) + + it.live("rejects malformed auth token query credentials", () => + Effect.gen(function* () { + yield* useAuth({ password: "secret" }) + + const response = yield* HttpClient.get("/probe?auth_token=not-base64") + + expect(response.status).toBe(401) + }), + ) +}) From cee9610d2674da2b876198ec097106ce591cc09e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 22:22:32 -0400 Subject: [PATCH 0025/1114] refactor: use Effect config for HttpApi authorization (#25035) --- .../opencode/src/effect/config-service.ts | 67 +++++++++++++++++++ .../httpapi/middleware/authorization.ts | 60 +++++++++-------- .../server/routes/instance/httpapi/server.ts | 6 +- .../test/server/httpapi-authorization.test.ts | 66 +++++------------- .../test/server/httpapi-bridge.test.ts | 43 +++++++++--- .../opencode/test/server/httpapi-sdk.test.ts | 28 +++++++- 6 files changed, 178 insertions(+), 92 deletions(-) create mode 100644 packages/opencode/src/effect/config-service.ts diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts new file mode 100644 index 000000000000..634673199f56 --- /dev/null +++ b/packages/opencode/src/effect/config-service.ts @@ -0,0 +1,67 @@ +import { Config, Context, Effect, Layer } from "effect" + +type ConfigMap = Record> + +/** + * The service shape inferred from an object of Effect `Config` definitions. + */ +export type Shape = { + readonly [Key in keyof Fields]: Config.Success +} + +/** + * A Context service class with generated layers for config-backed services. + */ +export type ServiceClass = Context.ServiceClass & { + /** Provide already-parsed config, useful in tests. */ + readonly layer: (input: Service) => Layer.Layer + /** Parse config once from the active Effect ConfigProvider and provide the service. */ + readonly defaultLayer: Layer.Layer +} + +/** + * Create a Context service whose implementation is derived from Effect `Config`. + * + * This keeps Effect `Config` as the source of truth for env names, defaults, and + * validation while generating a typed service plus convenient production/test + * layers. + * + * ```ts + * class ServerAuthConfig extends ConfigService.Service()( + * "@opencode/ServerAuthConfig", + * { + * password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), + * username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), + * }, + * ) {} + * + * const live = ServerAuthConfig.defaultLayer + * const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + * ``` + */ +export const Service = + () => + (id: Id, fields: Fields) => { + class ConfigTag extends Context.Service>()(id) { + static layer(input: Shape) { + return Layer.succeed(this, this.of(input)) + } + + static get defaultLayer() { + return Layer.effect( + this, + Config.all(fields) + .asEffect() + .pipe( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs. + Effect.map((config) => this.of(config as Shape)), + ), + ) + } + } + + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers. + return ConfigTag as ServiceClass> + } + +export * as ConfigService from "./config-service" 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 2fe196b5615a..b246140a0066 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,17 +1,11 @@ -import { Effect, Encoding, Layer, Redacted, Schema } from "effect" -import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { Flag } from "@opencode-ai/core/flag/flag" - -class Unauthorized extends Schema.TaggedErrorClass()( - "Unauthorized", - { message: Schema.String }, - { httpApiStatus: 401 }, -) {} +import { ConfigService } from "@/effect/config-service" +import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { - error: Unauthorized, + error: HttpApiError.UnauthorizedNoContent, security: { basic: HttpApiSecurity.basic, authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }), @@ -19,29 +13,38 @@ export class Authorization extends HttpApiMiddleware.Service()( }, ) {} -const emptyCredential = { - username: "", - password: Redacted.make(""), -} +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: typeof emptyCredential.password }, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, ) { return Effect.gen(function* () { - if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect + if (Option.isNone(config.password) || config.password.value === "") return yield* effect - if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) { - return yield* new Unauthorized({ message: "Unauthorized" }) + if (credential.username !== config.username) { + return yield* new HttpApiError.Unauthorized({}) } - if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { - return yield* new Unauthorized({ message: "Unauthorized" }) + if (Redacted.value(credential.password) !== config.password.value) { + return yield* new HttpApiError.Unauthorized({}) } return yield* effect }) } function decodeCredential(input: string) { + const emptyCredential = { + username: "", + password: Redacted.make(""), + } + return Encoding.decodeBase64String(input) .asEffect() .pipe( @@ -59,13 +62,16 @@ function decodeCredential(input: string) { ) } -export const authorizationLayer = Layer.succeed( +export const authorizationLayer = Layer.effect( Authorization, - Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential), - authToken: (effect, { credential }) => - Effect.gen(function* () { - return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential))) - }), + Effect.gen(function* () { + const config = yield* ServerAuthConfig + 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)), + ), + }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c0fb5a20a081..86b7182c739b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { InstanceHttpApi, RootHttpApi } from "./api" -import { authorizationLayer } from "./middleware/authorization" +import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -56,7 +56,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" -export const context = Context.empty() as Context.Context +export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => @@ -97,7 +97,7 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( ) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer, + authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 7dec8991643d..c3bab23ac720 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -1,10 +1,13 @@ import { NodeHttpServer } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +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, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { + Authorization, + ServerAuthConfig, + authorizationLayer, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -24,48 +27,19 @@ const apiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const testStateLayer = Layer.effectDiscard( - Effect.gen(function* () { - const original = { - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, - } - Flag.OPENCODE_SERVER_PASSWORD = undefined - Flag.OPENCODE_SERVER_USERNAME = undefined - yield* Effect.addFinalizer(() => - Effect.sync(() => { - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - }), - ) - }), -) +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 it = testEffect(apiLayer.pipe(Layer.provideMerge(testStateLayer))) +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 token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") -const useAuth = (input: { password: string; username?: string }) => - Effect.acquireRelease( - Effect.sync(() => { - const original = { - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, - } - Flag.OPENCODE_SERVER_PASSWORD = input.password - Flag.OPENCODE_SERVER_USERNAME = input.username - return original - }), - (original) => - Effect.sync(() => { - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - }), - ) - const getProbe = (headers?: Record) => HttpClientRequest.get("/probe").pipe( headers ? HttpClientRequest.setHeaders(headers) : (request) => request, @@ -82,10 +56,8 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("requires configured password for basic auth", () => + itSecret.live("requires configured password for basic auth", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const [missing, badPassword, good] = yield* Effect.all( [ getProbe(), @@ -101,10 +73,8 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("respects configured basic auth username", () => + itKitSecret.live("respects configured basic auth username", () => Effect.gen(function* () { - yield* useAuth({ username: "kit", password: "secret" }) - const [defaultUser, configuredUser] = yield* Effect.all( [getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })], { concurrency: "unbounded" }, @@ -115,20 +85,16 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("accepts auth token query credentials", () => + itSecret.live("accepts auth token query credentials", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) expect(response.status).toBe(200) }), ) - it.live("rejects malformed auth token query credentials", () => + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const response = yield* HttpClient.get("/probe?auth_token=not-base64") expect(response.status).toBe(401) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 5847192cb6aa..9343326738cd 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" +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" @@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - return Server.Default().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function openApiRouteKeys(spec: { paths: Record>> }) { @@ -94,9 +116,9 @@ type RequestBody = { required?: boolean } -function parameterKey(param: unknown) { - if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return - if (typeof param.in !== "string" || typeof param.name !== "string") return +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}` } @@ -105,27 +127,29 @@ function parameterSchema(input: { path: string method: (typeof methods)[number] name: string -}) { +}): unknown { const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, ) - if (!param || typeof param !== "object" || !("schema" in param)) return + if (!param || typeof param !== "object" || !("schema" in param)) return undefined return param.schema } function requestBodyKey(spec: OpenApiSpec, body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. const requestBody = body as RequestBody return JSON.stringify({ required: requestBody.required === true, content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)]) - .sort(), + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) + .sort(([left], [right]) => left.localeCompare(right)), }) } function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { if (!schema) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. const resolved = ( schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema ) as OpenApiSchema | undefined @@ -142,6 +166,7 @@ function responseContentTypes(input: { }) { const responses = input.spec.paths[input.path]?.[input.method]?.responses if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. const response = (responses as Record)[input.status] if (!response || typeof response !== "object" || !("content" in response)) return [] const content = (response as { content?: unknown }).content diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 66f48455a02b..6f3a0cb1cbbe 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { ConfigProvider, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" +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 { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string }) Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - return backend === "httpapi" ? Server.Default().app : Server.Legacy().app + if (backend === "legacy") return Server.Legacy().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function client( @@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator }>) { } function record(value: unknown) { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} + return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} } function array(value: unknown) { From c49bf0b402d54c453e8bfd39cce465bee9281a43 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 22:41:59 -0400 Subject: [PATCH 0026/1114] test: cover ConfigService helper (#25042) --- .../test/effect/config-service.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/opencode/test/effect/config-service.test.ts diff --git a/packages/opencode/test/effect/config-service.test.ts b/packages/opencode/test/effect/config-service.test.ts new file mode 100644 index 000000000000..be6f97736313 --- /dev/null +++ b/packages/opencode/test/effect/config-service.test.ts @@ -0,0 +1,65 @@ +import { describe, expect } from "bun:test" +import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect" +import { ConfigService } from "../../src/effect/config-service" +import { it } from "../lib/effect" + +class TestConfig extends ConfigService.Service()("@test/ConfigService", { + name: Config.string("NAME"), + token: Config.string("TOKEN").pipe(Config.option), + port: Config.number("PORT").pipe(Config.withDefault(3000)), +}) {} + +const fromConfig = (input: Record) => + TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + +const readConfig = TestConfig.useSync((config) => config) + +describe("ConfigService", () => { + it.effect("defaultLayer parses values from the active ConfigProvider", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + fromConfig({ + NAME: "kit", + TOKEN: "secret", + PORT: "4096", + }), + ), + ) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.some("secret")) + expect(config.port).toBe(4096) + }), + ) + + it.effect("defaultLayer applies Effect Config defaults", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" }))) + + expect(config.name).toBe("kit") + expect(config.token).toEqual(Option.none()) + expect(config.port).toBe(3000) + }), + ) + + it.effect("layer provides an already parsed service value", () => + Effect.gen(function* () { + const config = yield* readConfig.pipe( + Effect.provide( + TestConfig.layer({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + }), + ), + ) + + expect(config).toEqual({ + name: "direct", + token: Option.some("parsed"), + port: 9000, + } satisfies Context.Service.Shape) + }), + ) +}) From d7701dbfb6c80fa82948cae2ddaf4df3963b668e Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 30 Apr 2026 05:06:29 +0200 Subject: [PATCH 0027/1114] fix(opencode): preserve `external_dir` and `deny` parent permissions in task child sessions (#23290) --- packages/opencode/src/tool/task.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index bd8645d3c184..e58ea9b122cf 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -64,12 +64,16 @@ export const TaskTool = Tool.define( const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined + const parent = yield* sessions.get(ctx.sessionID) const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ + ...(parent.permission ?? []).filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), ...(canTodo ? [] : [ From 3ef0aaf768aa488db102454bee1df116c00f2c6f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:35:59 -0500 Subject: [PATCH 0028/1114] tweak: make azure onboarding ux a bit better (#25057) --- packages/opencode/src/cli/cmd/providers.ts | 46 +++++++++++++--------- packages/opencode/src/plugin/azure.ts | 26 ++++++++++++ packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/provider/provider.ts | 33 ++++++++++++---- 4 files changed, 82 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/plugin/azure.ts diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 61fe4b2da24e..278522555f2d 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -156,28 +156,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (method.type === "api") { - if (method.authorize) { - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + 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() + + const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} + if (!method.authorize) { + await put(provider, { + type: "api", + key, + ...metadata, }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - - const result = await method.authorize(inputs) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - await put(saveProvider, { - type: "api", - key: result.key ?? key, - }) - prompts.log.success("Login successful") - } prompts.outro("Done") return true } + + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + await put(saveProvider, { + type: "api", + key: result.key ?? key, + ...metadata, + }) + prompts.log.success("Login successful") + } + prompts.outro("Done") + return true } return false diff --git a/packages/opencode/src/plugin/azure.ts b/packages/opencode/src/plugin/azure.ts new file mode 100644 index 000000000000..62792b3bd27b --- /dev/null +++ b/packages/opencode/src/plugin/azure.ts @@ -0,0 +1,26 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" + +export async function AzureAuthPlugin(_input: PluginInput): Promise { + const prompts = [] + if (!process.env.AZURE_RESOURCE_NAME) { + prompts.push({ + type: "text" as const, + key: "resourceName", + message: "Enter Azure Resource Name", + placeholder: "e.g. my-models", + }) + } + + return { + auth: { + provider: "azure", + methods: [ + { + type: "api", + label: "API key", + prompts, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0313022c3685..ac37823c34e1 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -17,6 +17,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { AzureAuthPlugin } from "./azure" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -61,6 +62,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ PoeAuthPlugin, CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, + AzureAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 702435d7dabb..fc835cf5ee00 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -199,12 +199,26 @@ function custom(dep: CustomDep): Record { }), azure: Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() + const auth = yield* dep.auth(provider.id) const resource = iife(() => { - const name = provider.options?.resourceName - if (typeof name === "string" && name.trim() !== "") return name - return env["AZURE_RESOURCE_NAME"] + return [ + provider.options?.resourceName, + auth?.type === "api" ? auth.metadata?.resourceName : undefined, + env["AZURE_RESOURCE_NAME"], + ].find((name) => typeof name === "string" && name.trim() !== "") }) + if (!resource && !provider.options?.baseURL) { + return { + autoload: false, + async getModel() { + throw new Error( + "AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it", + ) + }, + } + } + return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { @@ -215,11 +229,16 @@ function custom(dep: CustomDep): Record { return sdk.responses(modelID) } }, - options: {}, - vars(_options) { - return { - ...(resource && { AZURE_RESOURCE_NAME: resource }), + options: { + resourceName: resource, + }, + vars(_options): Record { + if (resource) { + return { + AZURE_RESOURCE_NAME: resource, + } } + return {} }, } }), From 8ba374fefa51b220ada6be5e9fac487869a3b64c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Apr 2026 00:41:51 -0400 Subject: [PATCH 0029/1114] ci: enable sourcemaps for beta releases Generate linked sourcemaps when building beta releases to help users debug issues with readable stack traces. --- .github/workflows/publish.yml | 2 +- packages/opencode/script/build.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6cb6af0a8ddd..fd9b60f8bd3b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,7 +88,7 @@ jobs: - name: Build id: build run: | - ./packages/opencode/script/build.ts + ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }} diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 85e1e105f174..35812f953ddf 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -50,6 +50,7 @@ console.log(`Loaded ${migrations.length} migrations`) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") @@ -199,6 +200,7 @@ for (const item of targets) { external: ["node-gyp"], format: "esm", minify: true, + sourcemap: sourcemapsFlag ? "linked" : "none", splitting: true, compile: { autoloadBunfig: false, From 9bddf7f3ef5365eee0744374bcdbd1e9ec4b3e7b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:44:53 +1000 Subject: [PATCH 0030/1114] fix app crash restoring messages without model (#25062) --- packages/app/src/context/local.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 2db0f9b04f91..f467e9034fe7 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setSaved("session", session, { agent: msg.agent, model: msg.model, - variant: msg.model.variant ?? null, + variant: msg.model?.variant ?? null, }) }, }, From 3398fd7719dee3f17ec6601c6ae9bf7fc4d7c8a5 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:06:17 +0800 Subject: [PATCH 0031/1114] feat(httpapi): add CORS middleware to instance routes (#25074) --- .../server/routes/instance/httpapi/server.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 86b7182c739b..370696ddb228 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -55,6 +55,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 type { Predicate } from "effect/Predicate" export const context = Context.makeUnsafe(new Map()) @@ -104,6 +105,23 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( + Layer.provide( + HttpRouter.cors({ + maxAge: 86_400, + allowedOrigins: ((input) => { + return ( + !input || + input.startsWith("http://localhost:") || + input.startsWith("http://127.0.0.1:") || + input.startsWith("oc://renderer") || + input === "tauri://localhost" || + input === "http://tauri.localhost" || + input === "https://tauri.localhost" || + /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input) + ) + }) as Predicate as any, + }), + ), Layer.provide([ runtime, Account.defaultLayer, From 908e28175f9e5c46206c8884bdd9a7dbb31d0f0b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:10:39 +0800 Subject: [PATCH 0032/1114] fix: invert *_ready getters to fix server status indicator (#25077) --- packages/app/src/context/global-sync/child-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 4c3c677a75c6..a7209d3dbdcd 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -187,7 +187,7 @@ export function createChildStoreManager(input: { projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return providerQuery.isLoading + return !providerQuery.isLoading }, provider: { all: [], connected: [], default: {} }, config: {}, @@ -207,13 +207,13 @@ export function createChildStoreManager(input: { permission: {}, question: {}, get mcp_ready() { - return mcpQuery.isLoading + return !mcpQuery.isLoading }, get mcp() { return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) }, get lsp_ready() { - return lspQuery.isLoading + return !lspQuery.isLoading }, get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) From 62e1335388fdbadaa95d258b43f1c84740e6db1d Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:11:42 +0200 Subject: [PATCH 0033/1114] fix(opencode): allow oc://renderer origin in cors middleware (#25099) --- packages/opencode/src/server/middleware.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index c653156d33a7..95f14057064e 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -74,6 +74,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { if (input.startsWith("http://localhost:")) return input if (input.startsWith("http://127.0.0.1:")) return input + if (input.startsWith("oc://renderer")) return input if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") return input From dddfcbf0d8aa00e6b2744c8a9c111d489b8a4ca2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 11:07:00 -0400 Subject: [PATCH 0034/1114] test: port instance HttpApi path/vcs read coverage to Effect --- packages/opencode/src/pty/input.ts | 24 ++ packages/opencode/src/server/cors.ts | 12 + packages/opencode/src/server/middleware.ts | 12 +- .../routes/instance/httpapi/groups/mcp.ts | 1 + .../routes/instance/httpapi/groups/session.ts | 3 +- .../routes/instance/httpapi/handlers/pty.ts | 5 +- .../instance/httpapi/handlers/session.ts | 2 +- .../routes/instance/httpapi/handlers/tui.ts | 7 +- .../routes/instance/httpapi/lifecycle.ts | 61 +++-- .../server/routes/instance/httpapi/server.ts | 27 +-- packages/opencode/src/server/workspace.ts | 1 + packages/opencode/src/session/session.ts | 10 +- .../opencode/test/server/httpapi-cors.test.ts | 64 +++++ .../test/server/httpapi-event.test.ts | 15 +- .../server/httpapi-instance-context.test.ts | 59 ++++- .../server/httpapi-instance.legacy.test.ts | 138 +++++++++++ .../test/server/httpapi-instance.test.ts | 227 ++++++------------ .../test/server/httpapi-mcp-oauth.test.ts | 81 +++++++ .../test/server/httpapi-pty-websocket.test.ts | 16 ++ .../opencode/test/server/httpapi-sdk.test.ts | 2 +- .../test/server/httpapi-session.test.ts | 100 +++++++- .../opencode/test/server/httpapi-tui.test.ts | 40 ++- .../server/httpapi-workspace-routing.test.ts | 31 +++ 23 files changed, 717 insertions(+), 221 deletions(-) create mode 100644 packages/opencode/src/pty/input.ts create mode 100644 packages/opencode/src/server/cors.ts create mode 100644 packages/opencode/test/server/httpapi-cors.test.ts create mode 100644 packages/opencode/test/server/httpapi-instance.legacy.test.ts create mode 100644 packages/opencode/test/server/httpapi-mcp-oauth.test.ts create mode 100644 packages/opencode/test/server/httpapi-pty-websocket.test.ts diff --git a/packages/opencode/src/pty/input.ts b/packages/opencode/src/pty/input.ts new file mode 100644 index 000000000000..0e4ea9a61af0 --- /dev/null +++ b/packages/opencode/src/pty/input.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect" + +const inputDecoder = new TextDecoder("utf-8", { fatal: true }) + +export function handlePtyInput( + handler: { onMessage: (message: string | ArrayBuffer) => void }, + message: string | Uint8Array, +) { + if (typeof message === "string") { + handler.onMessage(message) + return Effect.void + } + return Effect.try({ + try: () => inputDecoder.decode(message), + catch: () => new Error("invalid PTY websocket input"), + }).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.flatMap((decoded) => { + if (decoded === undefined) return Effect.void + handler.onMessage(decoded) + return Effect.void + }), + ) +} diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts new file mode 100644 index 000000000000..8ae945b75244 --- /dev/null +++ b/packages/opencode/src/server/cors.ts @@ -0,0 +1,12 @@ +const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ + +export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) { + if (!input) return true + if (input.startsWith("http://localhost:")) return true + if (input.startsWith("http://127.0.0.1:")) return true + if (input.startsWith("oc://renderer")) return true + if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") + return true + if (opencodeOrigin.test(input)) return true + return opts?.cors?.includes(input) ?? false +} diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 95f14057064e..433f301ae403 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -11,6 +11,7 @@ import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" +import { isAllowedCorsOrigin } from "./cors" const log = Log.create({ service: "server" }) @@ -70,16 +71,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { return cors({ maxAge: 86_400, origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if (input.startsWith("oc://renderer")) return input - if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") - return input - - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input - if (opts?.cors?.includes(input)) return input + if (isAllowedCorsOrigin(input, opts)) return input }, }) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index e9caf0cd9d7d..b30714c196a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -15,6 +15,7 @@ export const AddPayload = Schema.Struct({ export const StatusMap = Schema.Record(Schema.String, MCP.Status) export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, + oauthState: Schema.String, }) export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index bc26a9e59724..77d064ff5a28 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,7 +10,6 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { NonNegativeInt } from "@/util/schema" import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -45,7 +44,7 @@ export const UpdatePayload = Schema.Struct({ permission: Schema.optional(Permission.Ruleset), time: Schema.optional( Schema.Struct({ - archived: Schema.optional(NonNegativeInt), + archived: Schema.optional(Session.ArchivedTimestamp), }), ), }) 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 8558ee793cd4..aa151cecec06 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,6 +1,7 @@ import { EffectBridge } from "@/effect/bridge" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -102,9 +103,7 @@ export const ptyConnectRoute = HttpRouter.add( if (!handler) return HttpServerResponse.empty() yield* socket - .runRaw((message) => { - handler.onMessage(typeof message === "string" ? message : message.slice().buffer) - }) + .runRaw((message) => handlePtyInput(handler, message)) .pipe( Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), Effect.ensuring( 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 65c90b952995..3d88db60db5e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -62,7 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return Instance.restore(instance, () => Array.from( Session.list({ - directory: ctx.query.directory, + directory: ctx.query.scope === "project" ? undefined : ctx.query.directory, scope: ctx.query.scope, path: ctx.query.path, roots: ctx.query.roots, 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 cb12ccb7a704..c7c447ce85b3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -28,8 +28,8 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) => - bus.publish(TuiEvent.CommandExecute, { command }) + const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => + bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { payload: typeof TuiEvent.PromptAppend.properties.Type @@ -71,7 +71,8 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: { payload: typeof CommandPayload.Type }) { - yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command) + // Legacy only publishes known aliases; unknown commands become undefined. + yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases]) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index c93261a0be87..7b263980c554 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,32 +1,63 @@ +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 { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" -const disposeAfterResponse = new WeakMap() +type MarkedInstance = { + ctx: InstanceContext + workspaceID?: WorkspaceID +} -export const markInstanceForDisposal = (ctx: InstanceContext) => - HttpEffect.appendPreResponseHandler((request, response) => - Effect.sync(() => { - disposeAfterResponse.set(request.source, ctx) - return response +// Disposal is requested by an endpoint handler, but must run from the outer +// server middleware after the response has been produced. The original Request +// object is the stable handoff key between those two phases. +const disposeAfterResponse = new WeakMap() + +const mark = (ctx: InstanceContext) => + Effect.gen(function* () { + return { ctx, workspaceID: yield* WorkspaceRef } + }) + +// 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) + return yield* HttpEffect.appendPreResponseHandler((request, response) => + Effect.sync(() => { + // The response is sent before disposeMiddleware performs the teardown. + disposeAfterResponse.set(request.source, marked) + return response + }), + ) + }) + export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => - HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as( - Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), - response, - ), - ) + Effect.gen(function* () { + const marked = yield* mark(ctx) + return yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response), + ) + }) export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => Effect.gen(function* () { const response = yield* effect const request = yield* HttpServerRequest.HttpServerRequest - const ctx = disposeAfterResponse.get(request.source) - if (!ctx) return response + const marked = disposeAfterResponse.get(request.source) + if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))) + yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose())) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 370696ddb228..e0ce5248569e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" @@ -31,6 +31,7 @@ import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { isAllowedCorsOrigin } from "@/server/cors" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" import { eventRoute } from "./event" @@ -55,7 +56,6 @@ 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 type { Predicate } from "effect/Predicate" export const context = Context.makeUnsafe(new Map()) @@ -69,6 +69,11 @@ const runtime = HttpRouter.middleware()( ), ).layer +const cors = HttpRouter.middleware(HttpMiddleware.cors({ + allowedOrigins: isAllowedCorsOrigin, + maxAge: 86_400, +}), { global: true }) + const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ @@ -105,24 +110,8 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( - Layer.provide( - HttpRouter.cors({ - maxAge: 86_400, - allowedOrigins: ((input) => { - return ( - !input || - input.startsWith("http://localhost:") || - input.startsWith("http://127.0.0.1:") || - input.startsWith("oc://renderer") || - input === "tauri://localhost" || - input === "http://tauri.localhost" || - input === "https://tauri.localhost" || - /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input) - ) - }) as Predicate as any, - }), - ), Layer.provide([ + cors, runtime, Account.defaultLayer, Agent.defaultLayer, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 29b1ab986909..c22a09bda9a1 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -17,6 +17,7 @@ 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" }, ] diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 1be5dfffd42d..9a50a9a98045 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -142,11 +142,15 @@ 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 + const Time = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(ArchivedTimestamp), }) const Revert = Schema.Struct({ @@ -215,7 +219,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema ) export const SetArchivedInput = Schema.Struct({ sessionID: SessionID, - time: Schema.optional(NonNegativeInt), + time: Schema.optional(ArchivedTimestamp), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export const SetPermissionInput = Schema.Struct({ sessionID: SessionID, @@ -244,7 +248,7 @@ const UpdatedTime = Schema.Struct({ created: Schema.optional(Schema.NullOr(NonNegativeInt)), updated: Schema.optional(Schema.NullOr(NonNegativeInt)), compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), }) const UpdatedInfo = Schema.Struct({ diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts new file mode 100644 index 000000000000..3330cfdd11aa --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -0,0 +1,64 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Config, Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +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 { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + } + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_SERVER_PASSWORD = "secret" + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +describe("HttpApi CORS", () => { + it.live("allows browser preflight requests without credentials", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.options(InstancePaths.path).pipe( + HttpClientRequest.setHeaders({ + origin: "http://localhost:3000", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }), + HttpClient.execute, + ) + + expect(response.status).toBe(204) + expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000") + expect(response.headers["access-control-allow-headers"]).toBe("authorization") + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 6fe92a23463b..915d79784cfd 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -11,9 +11,9 @@ void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } async function readFirstChunk(response: Response) { @@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("x-content-type-options")).toBe("nosniff") expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') }) + + test("matches legacy first event frame", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path } + 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)) + }) }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 74b1ecdeba81..aec3743e60b6 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,7 +1,8 @@ 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, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" @@ -12,6 +13,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" 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" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" @@ -84,6 +86,40 @@ 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 serveDisposeProbe = () => + HttpRouter.serve( + HttpRouter.add( + "POST", + "/dispose-probe", + Effect.gen(function* () { + const instance = yield* InstanceRef + if (!instance) return HttpServerResponse.empty({ status: 500 }) + yield* markInstanceForDisposal(instance) + return yield* HttpServerResponse.json(true) + }), + ).pipe(Layer.provide(instanceContextTestLayer)), + { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }, + ).pipe(Layer.build) + describe("HttpApi instance context middleware", () => { it.live("provides instance context from the routed directory", () => Effect.gen(function* () { @@ -164,4 +200,25 @@ describe("HttpApi instance context middleware", () => { }) }), ) + + it.live("preserves selected workspace id on instance disposal events", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-dispose-event", + directory: workspaceDir, + }) + yield* serveDisposeProbe() + const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped) + + const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute) + + expect(response.status).toBe(200) + expect(yield* response.json).toBe(true) + expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id }) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts new file mode 100644 index 000000000000..4f9ccc512a63 --- /dev/null +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -0,0 +1,138 @@ +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 { tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().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) + }) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await Instance.disposeAll() + await resetDatabase() +}) + +describe("instance HttpApi", () => { + test("serves catalog read endpoints through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const [commands, agents, skills, lsp, formatter] = await Promise.all([ + app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), + app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), + ]) + + expect(commands.status).toBe(200) + expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) + + expect(agents.status).toBe(200) + expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) + + expect(skills.status).toBe(200) + expect(await skills.json()).toBeArray() + + expect(lsp.status).toBe(200) + expect(await lsp.json()).toEqual([]) + + expect(formatter.status).toBe(200) + expect(await formatter.json()).toEqual([]) + }) + + test("serves project git init through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const disposed = waitDisposed(tmp.path) + + const response = await app().request("/project/git/init", { + method: "POST", + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + await disposed + + const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) + }) + + test("serves project update through Hono bridge", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) + expect(current.status).toBe(200) + const project = (await current.json()) as { id: string } + + const response = await app().request(`/project/${project.id}`, { + method: "PATCH", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + id: project.id, + name: "patched-project", + commands: { start: "bun dev" }, + }) + + const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) + expect(list.status).toBe(200) + expect(await list.json()).toContainEqual( + expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), + ) + }) + + 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 response = await app().request(InstancePaths.dispose, { + method: "POST", + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + expect(await disposed).toBe(tmp.path) + }) +}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 8e48284deaa6..3d9245cd6ff9 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,164 +1,83 @@ -import { afterEach, describe, expect, test } from "bun:test" -import path from "path" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" 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 { describe, expect } from "bun:test" +import { Config, Effect, FileSystem, Layer, Path } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import * as Log from "@opencode-ai/core/util/log" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().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) - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() - await resetDatabase() -}) - -describe("instance HttpApi", () => { - test("serves path and VCS read endpoints through Hono bridge", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(path.join(tmp.path, "changed.txt"), "hello") - - const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`) - vcsDiff.searchParams.set("mode", "git") - - const [paths, vcs, diff] = await Promise.all([ - app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }), - app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }), - ]) - - expect(paths.status).toBe(200) - expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path }) - - expect(vcs.status).toBe(200) - expect(await vcs.json()).toMatchObject({ branch: expect.any(String) }) - - expect(diff.status).toBe(200) - expect(await diff.json()).toContainEqual( - expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }), +import { 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. +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + await resetDatabase() + }), ) - }) - - test("serves catalog read endpoints through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + }), +) - const [commands, agents, skills, lsp, formatter] = await Promise.all([ - app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), - ]) +// Mount the production HttpApi route tree on a real Node HTTP server bound to +// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This +// keeps the test wired through the same route layer production uses, without +// going through Server.Default()/Hono. +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) - expect(commands.status).toBe(200) - expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) +const httpApiServerLayer = servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) - expect(agents.status).toBe(200) - expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) +const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) - expect(skills.status).toBe(200) - expect(await skills.json()).toBeArray() +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) - expect(lsp.status).toBe(200) - expect(await lsp.json()).toEqual([]) - - expect(formatter.status).toBe(200) - expect(await formatter.json()).toEqual([]) - }) - - test("serves project git init through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const disposed = waitDisposed(tmp.path) - - const response = await app().request("/project/git/init", { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - await disposed - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - }) - - test("serves project update through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - const project = (await current.json()) as { id: string } - - const response = await app().request(`/project/${project.id}`, { - method: "PATCH", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, - body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - id: project.id, - name: "patched-project", - commands: { start: "bun dev" }, - }) - - const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) - expect(list.status).toBe(200) - expect(await list.json()).toContainEqual( - expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), - ) - }) - - 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 response = await app().request(InstancePaths.dispose, { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) - }) +describe("instance HttpApi", () => { + it.live("serves path and VCS read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello") + + const [paths, vcs, diff] = yield* Effect.all( + [ + HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute), + HttpClientRequest.get(InstancePaths.vcsDiff).pipe( + HttpClientRequest.setUrlParam("mode", "git"), + directoryHeader(dir), + HttpClient.execute, + ), + ], + { concurrency: "unbounded" }, + ) + + expect(paths.status).toBe(200) + expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir }) + + expect(vcs.status).toBe(200) + expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) }) + + expect(diff.status).toBe(200) + expect(yield* diff.json).toContainEqual( + expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }), + ) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts new file mode 100644 index 000000000000..5d2f6f474d28 --- /dev/null +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -0,0 +1,81 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Session } from "@/session/session" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { + WorkspaceRouteContext, + WorkspaceRoutingMiddleware, +} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { testEffect } from "../lib/effect" + +const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi) +const fakeSession = Layer.mock(Session.Service)({}) +const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => + Effect.succeed( + handlers + .handle("status", () => Effect.die("unexpected MCP status")) + .handle("add", () => Effect.die("unexpected MCP add")) + .handle("authStart", () => + Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }), + ) + .handle("authCallback", () => Effect.die("unexpected MCP authCallback")) + .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) + .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) + .handle("connect", () => Effect.die("unexpected MCP connect")) + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")), + ), +) + +const passthroughAuthorization = Layer.succeed( + Authorization, + Authorization.of({ + basic: (effect) => effect, + authToken: (effect) => effect, + }), +) + +const passthroughInstanceContext = Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => effect), +) + +const testWorkspaceRouting = Layer.succeed( + WorkspaceRoutingMiddleware, + WorkspaceRoutingMiddleware.of((effect) => + effect.pipe( + Effect.provideService( + WorkspaceRouteContext, + WorkspaceRouteContext.of({ directory: process.cwd() }), + ), + ), + ), +) + +const it = testEffect( + HttpRouter.serve( + HttpApiBuilder.layer(TestHttpApi).pipe( + Layer.provide(testMcpHandlers), + Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), +) + +describe("mcp HttpApi OAuth", () => { + it.live("preserves oauth state when starting OAuth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + authorizationUrl: "https://auth.example/start", + oauthState: "state-123", + }) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-pty-websocket.test.ts b/packages/opencode/test/server/httpapi-pty-websocket.test.ts new file mode 100644 index 000000000000..81ee952d9689 --- /dev/null +++ b/packages/opencode/test/server/httpapi-pty-websocket.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { handlePtyInput } from "../../src/pty/input" + +describe("pty HttpApi websocket input", () => { + test("does not forward invalid binary frames to the PTY handler", async () => { + const messages: Array = [] + const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) } + + await Effect.runPromise(handlePtyInput(handler, "ready")) + await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))) + await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello"))) + + expect(messages).toEqual(["ready", "hello"]) + }) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6f3a0cb1cbbe..596ca4a5c4bc 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -404,7 +404,7 @@ describe("HttpApi SDK", () => { lsp, }), project: { worktreeSelected: record(project.data).worktree === directory }, - paths: { cwdSelected: record(paths.data).cwd === directory }, + paths: { directorySelected: record(paths.data).directory === directory }, file: record(file.data).content, hasProject: array(projects.data).length > 0, foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 593f9765c7f0..58e02ef0fcee 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { PermissionID } from "../../src/permission/schema" @@ -9,7 +11,10 @@ import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/se 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 * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -18,9 +23,9 @@ void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app } function runSession(fx: Effect.Effect) { @@ -76,6 +81,10 @@ function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } +function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { + return Effect.promise(async () => app(experimental).request(path, init)) +} + function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -217,6 +226,91 @@ describe("session HttpApi", () => { ), ) + it.live( + "matches legacy archived timestamp validation", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const legacy = yield* createSession(tmp.path, { title: "legacy" }) + const effect = yield* createSession(tmp.path, { title: "effect" }) + const body = JSON.stringify({ time: { archived: -1 } }) + + const legacyResponse = yield* requestWithBackend(false, pathFor(SessionPaths.update, { sessionID: legacy.id }), { + method: "PATCH", + headers, + body, + }) + expect(legacyResponse.status).toBe(200) + expect((yield* json(legacyResponse)).time.archived).toBe(-1) + + const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { + method: "PATCH", + headers, + body, + }) + expect(effectResponse.status).toBe(legacyResponse.status) + expect((yield* json(effectResponse)).time.archived).toBe(-1) + }), + ), + ) + + it.live( + "matches legacy project-scoped path and directory precedence", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const currentDir = path.join(tmp.path, "packages", "opencode", "src") + yield* Effect.promise(() => mkdir(currentDir, { recursive: true })) + + const pathSession = yield* createSession(currentDir) + const pathlessSession = yield* createSession(currentDir) + yield* Effect.sync(() => + Database.use((db) => + db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(), + ), + ) + + const query = new URLSearchParams({ + scope: "project", + path: "packages/opencode/src", + directory: currentDir, + }) + const headers = { "x-opencode-directory": tmp.path } + const legacy = (yield* json( + yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + const effect = (yield* json( + yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) + + expect(legacy).toContain(pathSession.id) + expect(legacy).not.toContain(pathlessSession.id) + expect(effect).toEqual(legacy) + }), + ), + ) + + it.live( + "matches legacy paginated message link headers", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const headers = { "x-opencode-directory": tmp.path } + const session = yield* createSession(tmp.path, { title: "messages" }) + yield* createTextMessage(tmp.path, session.id, "first") + yield* createTextMessage(tmp.path, session.id, "second") + const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` + + const legacy = yield* requestWithBackend(false, route, { headers }) + const effect = yield* requestWithBackend(true, route, { headers }) + + expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) + expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) + expect(effect.headers.get("access-control-expose-headers")).toBe( + legacy.headers.get("access-control-expose-headers"), + ) + }), + ), + ) + it.live( "serves message mutation routes through Hono bridge", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 9f7c8e9e8994..3e844fad02f5 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,6 +1,8 @@ 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" import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" @@ -15,9 +17,20 @@ void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app +function app(experimental = true) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +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) + }) } async function expectTrue(path: string, headers: Record, body?: unknown) { @@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => { expect(missing.status).toBe(404) }) + test("matches legacy unknown execute command behavior", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + const body = JSON.stringify({ command: "unknown_command" }) + + const legacyCommand = nextCommandExecute() + const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(legacy.status).toBe(200) + expect(await legacy.json()).toBe(true) + + const effectCommand = nextCommandExecute() + const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) + expect(effect.status).toBe(200) + expect(await effect.json()).toBe(true) + + const legacyPublished = await legacyCommand + const effectPublished = await effectCommand + expect(effectPublished).toBe(legacyPublished) + expect(legacyPublished).toBeUndefined() + }) + test("serves TUI control queue through experimental Effect routes", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 6d0649922454..b52b95d86c5c 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -20,6 +20,7 @@ import type { WorkspaceAdaptor } 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" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { WorkspaceRouteContext, workspaceRouterMiddleware, @@ -387,6 +388,36 @@ describe("HttpApi workspace routing middleware", () => { }), ) + it.live("keeps workspace control routes local even when workspace is selected", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "workspace-control-plane-target", + directory: workspaceDir, + }) + + // Workspace CRUD/status routes manage the control plane itself. Selecting + // a workspace should preserve the selected id for handlers, but must not + // swap the route context to the workspace target directory. + yield* HttpRouter.add( + "GET", + WorkspacePaths.list, + Effect.gen(function* () { + const route = yield* WorkspaceRouteContext + return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID }) + }), + ).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id }) + }), + ) + it.live("uses directory query/header fallback when no workspace is selected", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() From 29b1060c6741279a25a3ba30f2c25c065a37ce1c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 15:08:03 +0000 Subject: [PATCH 0035/1114] chore: generate --- .../src/server/routes/instance/httpapi/server.ts | 11 +++++++---- .../test/server/httpapi-instance-context.test.ts | 4 +++- .../opencode/test/server/httpapi-mcp-oauth.test.ts | 7 +------ .../opencode/test/server/httpapi-session.test.ts | 14 +++++++++----- packages/sdk/openapi.json | 12 +++--------- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e0ce5248569e..d8208c765714 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -69,10 +69,13 @@ const runtime = HttpRouter.middleware()( ), ).layer -const cors = HttpRouter.middleware(HttpMiddleware.cors({ - allowedOrigins: isAllowedCorsOrigin, - maxAge: 86_400, -}), { global: true }) +const cors = HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: isAllowedCorsOrigin, + maxAge: 86_400, + }), + { global: true }, +) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index aec3743e60b6..0817b9003604 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -214,7 +214,9 @@ describe("HttpApi instance context middleware", () => { yield* serveDisposeProbe() const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped) - const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(HttpClient.execute) + const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe( + HttpClient.execute, + ) expect(response.status).toBe(200) expect(yield* response.json).toBe(true) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 5d2f6f474d28..829f899605de 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -47,12 +47,7 @@ const passthroughInstanceContext = Layer.succeed( const testWorkspaceRouting = Layer.succeed( WorkspaceRoutingMiddleware, WorkspaceRoutingMiddleware.of((effect) => - effect.pipe( - Effect.provideService( - WorkspaceRouteContext, - WorkspaceRouteContext.of({ directory: process.cwd() }), - ), - ), + effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() }))), ), ) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 58e02ef0fcee..75e4a3ac9b1d 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -235,11 +235,15 @@ describe("session HttpApi", () => { const effect = yield* createSession(tmp.path, { title: "effect" }) const body = JSON.stringify({ time: { archived: -1 } }) - const legacyResponse = yield* requestWithBackend(false, pathFor(SessionPaths.update, { sessionID: legacy.id }), { - method: "PATCH", - headers, - body, - }) + const legacyResponse = yield* requestWithBackend( + false, + pathFor(SessionPaths.update, { sessionID: legacy.id }), + { + method: "PATCH", + headers, + body, + }, + ) expect(legacyResponse.status).toBe(200) expect((yield* json(legacyResponse)).time.archived).toBe(-1) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 65c1d810c580..bfca971ef166 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10341,9 +10341,7 @@ "maximum": 9007199254740991 }, "archived": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, "required": ["created", "updated"] @@ -10854,9 +10852,7 @@ "archived": { "anyOf": [ { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" }, { "type": "null" @@ -12773,9 +12769,7 @@ "maximum": 9007199254740991 }, "archived": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, "required": ["created", "updated"] From fe0c182747bfd28b10c58cf6abb8345c66fcebe4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 30 Apr 2026 17:33:54 +0200 Subject: [PATCH 0036/1114] upgrade opentui to 0.2.0 (#24810) --- bun.lock | 70 ++++++++++++++++++++++++++---------- package.json | 4 +-- packages/plugin/package.json | 4 +-- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index 01d63a24ab89..64c372661f61 100644 --- a/bun.lock +++ b/bun.lock @@ -506,8 +506,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.105", - "@opentui/solid": ">=0.1.105", + "@opentui/core": ">=0.2.0", + "@opentui/solid": ">=0.2.0", }, "optionalPeers": [ "@opentui/core", @@ -685,8 +685,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.1.105", - "@opentui/solid": "0.1.105", + "@opentui/core": "0.2.0", + "@opentui/solid": "0.2.0", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", @@ -1613,21 +1613,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + "@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], - "@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "@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=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2731,15 +2731,15 @@ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "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": ["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-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], - "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-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -5597,8 +5597,6 @@ "@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=="], - "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], @@ -5951,6 +5949,10 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + + "opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6711,6 +6713,24 @@ "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + + "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + + "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + + "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + + "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + + "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/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=="], + + "opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -7053,6 +7073,18 @@ "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=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/package.json b/package.json index 2e53fab9cc5f..975f1777439c 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.1.105", - "@opentui/solid": "0.1.105", + "@opentui/core": "0.2.0", + "@opentui/solid": "0.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ad580a187c85..9082f1183103 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.1.105", - "@opentui/solid": ">=0.1.105" + "@opentui/core": ">=0.2.0", + "@opentui/solid": ">=0.2.0" }, "peerDependenciesMeta": { "@opentui/core": { From 53e9cac383859f7bb33f771db3ac6967cf4a98a7 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 30 Apr 2026 11:44:58 -0400 Subject: [PATCH 0037/1114] refactor(core): convert control-plane workspace to Effect (#25018) --- .../src/control-plane/adaptors/index.ts | 13 +- .../src/control-plane/adaptors/worktree.ts | 18 +- .../opencode/src/control-plane/dev/README.md | 20 + packages/opencode/src/control-plane/sse.ts | 66 - packages/opencode/src/control-plane/util.ts | 14 +- .../opencode/src/control-plane/workspace.ts | 1153 ++++++++------ packages/opencode/src/effect/app-runtime.ts | 2 + .../httpapi/middleware/workspace-routing.ts | 4 +- .../opencode/test/control-plane/sse.test.ts | 56 - .../test/control-plane/workspace.test.ts | 1391 +++++++++++++++++ .../test/workspace/workspace-restore.test.ts | 283 ---- 11 files changed, 2155 insertions(+), 865 deletions(-) create mode 100644 packages/opencode/src/control-plane/dev/README.md delete mode 100644 packages/opencode/src/control-plane/sse.ts delete mode 100644 packages/opencode/test/control-plane/sse.test.ts create mode 100644 packages/opencode/test/control-plane/workspace.test.ts delete mode 100644 packages/opencode/test/workspace/workspace-restore.test.ts diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index 651d09cc2118..c91f534b5a48 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,27 +1,26 @@ -import { lazy } from "@/util/lazy" import type { ProjectID } from "@/project/schema" import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" +import { WorktreeAdaptor } from "./worktree" -const BUILTIN: Record Promise> = { - worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), +const BUILTIN: Record = { + worktree: WorktreeAdaptor, } const state = new Map>() -export async function getAdaptor(projectID: ProjectID, type: string): Promise { +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() + 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, init]) => { - const adaptor = await init() + Object.entries(BUILTIN).map(async ([type, adaptor]) => { return { type, name: adaptor.name, diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 9c080daa385a..de9618d302b0 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import { AppRuntime } from "@/effect/app-runtime" -import { Worktree } from "@/worktree" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ @@ -10,19 +8,26 @@ const WorktreeConfig = Schema.Struct({ }) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) +async function loadWorktree() { + const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) + return { AppRuntime, Worktree } +} + export const WorktreeAdaptor: WorkspaceAdaptor = { name: "Worktree", description: "Create a git worktree", async configure(info) { - const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const { AppRuntime, Worktree } = await loadWorktree() + const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) return { ...info, - name: worktree.name, - branch: worktree.branch, - directory: worktree.directory, + name: next.name, + branch: next.branch, + directory: next.directory, } }, async create(info) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise( Worktree.Service.use((svc) => @@ -35,6 +40,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = { ) }, async remove(info) { + const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory }))) }, diff --git a/packages/opencode/src/control-plane/dev/README.md b/packages/opencode/src/control-plane/dev/README.md new file mode 100644 index 000000000000..dbd62c0b1feb --- /dev/null +++ b/packages/opencode/src/control-plane/dev/README.md @@ -0,0 +1,20 @@ + +This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`: + +```json + "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"], +``` + +In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it: + +``` +./packages/opencode/script/run-workspace-server +``` + +With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above. + +How this works: + +* The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written. +* The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server. +* The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist. \ No newline at end of file diff --git a/packages/opencode/src/control-plane/sse.ts b/packages/opencode/src/control-plane/sse.ts deleted file mode 100644 index 003093a00379..000000000000 --- a/packages/opencode/src/control-plane/sse.ts +++ /dev/null @@ -1,66 +0,0 @@ -export async function parseSSE( - body: ReadableStream, - signal: AbortSignal, - onEvent: (event: unknown) => void, -) { - const reader = body.getReader() - const decoder = new TextDecoder() - let buf = "" - let last = "" - let retry = 1000 - - const abort = () => { - void reader.cancel().catch(() => undefined) - } - - signal.addEventListener("abort", abort) - - try { - while (!signal.aborted) { - const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined })) - if (chunk.done) break - - buf += decoder.decode(chunk.value, { stream: true }) - buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - - const chunks = buf.split("\n\n") - buf = chunks.pop() ?? "" - - chunks.forEach((chunk) => { - const data: string[] = [] - chunk.split("\n").forEach((line) => { - if (line.startsWith("data:")) { - data.push(line.replace(/^data:\s*/, "")) - return - } - if (line.startsWith("id:")) { - last = line.replace(/^id:\s*/, "") - return - } - if (line.startsWith("retry:")) { - const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) - if (!Number.isNaN(parsed)) retry = parsed - } - }) - - if (!data.length) return - const raw = data.join("\n") - try { - onEvent(JSON.parse(raw)) - } catch { - onEvent({ - type: "sse.message", - properties: { - data: raw, - id: last || undefined, - retry, - }, - }) - } - }) - } - } finally { - signal.removeEventListener("abort", abort) - reader.releaseLock() - } -} diff --git a/packages/opencode/src/control-plane/util.ts b/packages/opencode/src/control-plane/util.ts index 023c2ae1503e..35bc87163b7a 100644 --- a/packages/opencode/src/control-plane/util.ts +++ b/packages/opencode/src/control-plane/util.ts @@ -1,22 +1,23 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Effect } from "effect" export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) { - if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted")) + if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted")) - return new Promise((resolve, reject) => { + return Effect.callback((resume) => { const abort = () => { cleanup() - reject(input.signal?.reason ?? new Error("Request aborted")) + resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted"))) } const handler = (event: GlobalEvent) => { try { if (!input.fn(event)) return cleanup() - resolve() + resume(Effect.void) } catch (error) { cleanup() - reject(error) + resume(Effect.fail(error)) } } @@ -28,10 +29,11 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e const timeout = setTimeout(() => { cleanup() - reject(new Error("Timed out waiting for global event")) + resume(Effect.fail(new Error("Timed out waiting for global event"))) }, input.timeout) GlobalBus.on("event", handler) input.signal?.addEventListener("abort", abort, { once: true }) + return Effect.sync(cleanup) }) } diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c56ff2631034..2d8c5704419d 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,5 +1,5 @@ -import { Schema } from "effect" -import { setTimeout as sleep } from "node:timers/promises" +import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { fn } from "@/util/fn" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" @@ -20,12 +20,11 @@ import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" -import { parseSSE } from "./sse" import { Session } from "@/session/session" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" -import { AppRuntime } from "@/effect/app-runtime" +import { makeRuntime } from "@/effect/run-service" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { NonNegativeInt, withStatics } from "@/util/schema" @@ -76,6 +75,11 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { } } +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +const log = Log.create({ service: "workspace-sync" }) + export const CreateInput = Schema.Struct({ id: Schema.optional(WorkspaceID), type: Info.fields.type, @@ -85,286 +89,739 @@ export const CreateInput = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const create = fn(CreateInput.zod, async (input) => { - const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.projectID, input.type) +export const SessionRestoreInput = Schema.Struct({ + workspaceID: WorkspaceID, + sessionID: SessionID, +}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +export type SessionRestoreInput = Schema.Schema.Type - const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) +export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { + message: Schema.String, + status: Schema.Number, + body: Schema.optional(Schema.String), +}) {} - const info: Info = { - id, - type: config.type, - branch: config.branch ?? null, - name: config.name ?? null, - directory: config.directory ?? null, - extra: config.extra ?? null, - projectID: input.projectID, - } +export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()("WorkspaceNotFoundError", { + message: Schema.String, + workspaceID: WorkspaceID, +}) {} + +export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceSessionEventsNotFoundError", + { + message: Schema.String, + sessionID: SessionID, + }, +) {} + +export class SessionRestoreHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionRestoreHttpError", + { + message: Schema.String, + workspaceID: WorkspaceID, + sessionID: SessionID, + status: Schema.Number, + body: Schema.String, + }, +) {} + +export class SyncTimeoutError extends Schema.TaggedErrorClass()("WorkspaceSyncTimeoutError", { + message: Schema.String, + state: Schema.Record(Schema.String, Schema.Number), +}) {} + +export class SyncAbortedError extends Schema.TaggedErrorClass()("WorkspaceSyncAbortedError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +type CreateError = Auth.AuthError +type SessionRestoreError = + | WorkspaceNotFoundError + | SessionEventsNotFoundError + | SessionRestoreHttpError + | 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 list: (project: Project.Info) => Effect.Effect + readonly get: (id: WorkspaceID) => Effect.Effect + readonly remove: (id: WorkspaceID) => Effect.Effect + readonly status: () => Effect.Effect + readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect + readonly waitForSync: ( + workspaceID: WorkspaceID, + state: Record, + signal?: AbortSignal, + ) => Effect.Effect + readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Workspace") {} - Database.use((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - type: info.type, - branch: info.branch, - name: info.name, - directory: info.directory, - extra: info.extra, - project_id: info.projectID, +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const session = yield* Session.Service + const http = yield* HttpClient.HttpClient + const connections = new Map() + const syncFibers = yield* FiberMap.make() + + const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => { + const prev = connections.get(id) + if (prev?.status === status) return + const next = { workspaceID: id, status } + connections.set(id, next) + + GlobalBus.emit("event", { + directory: "global", + workspace: id, + payload: { + type: Event.Status.type, + properties: next, + }, }) - .run() - }) + } - const env = { - OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))), - OPENCODE_WORKSPACE_ID: config.id, - OPENCODE_EXPERIMENTAL_WORKSPACES: "true", - OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, - OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, - OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, - } - await adaptor.create(config, env) + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( + url: URL | string, + headers: HeadersInit | undefined, + ) { + const response = yield* http.execute( + HttpClientRequest.get(route(url, "/global/event"), { + headers: new Headers(headers), + accept: "text/event-stream", + }), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new SyncHttpError({ + message: `Workspace sync HTTP failure: ${response.status}`, + status: response.status, + }) + } + return response.stream + }) + + const parseSSE = Effect.fn("Workspace.parseSSE")(function* ( + stream: Stream.Stream, + onEvent: (event: unknown) => Effect.Effect, + ) { + yield* stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.mapAccum( + () => ({ data: [] as string[], id: undefined as string | undefined, retry: 1000 }), + (state, line) => { + if (line === "") { + if (!state.data.length) return [state, []] + return [{ ...state, data: [] }, [{ data: state.data.join("\n"), id: state.id, retry: state.retry }]] + } + + const index = line.indexOf(":") + const field = index === -1 ? line : line.slice(0, index) + const value = index === -1 ? "" : line.slice(index + (line[index + 1] === " " ? 2 : 1)) + + if (field === "data") return [{ ...state, data: [...state.data, value] }, []] + if (field === "id") return [{ ...state, id: value }, []] + if (field === "retry") { + const retry = Number.parseInt(value, 10) + return [Number.isNaN(retry) ? state : { ...state, retry }, []] + } + return [state, []] + }, + { + onHalt: (state) => + state.data.length ? [{ data: state.data.join("\n"), id: state.id, retry: state.retry }] : [], + }, + ), + Stream.map((event) => { + try { + return JSON.parse(event.data) as unknown + } catch { + return { + type: "sse.message", + properties: { + data: event.data, + id: event.id || undefined, + retry: event.retry, + }, + } + } + }), + Stream.runForEach(onEvent), + ) + }) + + const syncHistory = Effect.fn("Workspace.syncHistory")(function* ( + space: Info, + url: URL | string, + headers: HeadersInit | undefined, + ) { + const sessionIDs = yield* db((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.workspace_id, space.id)) + .all() + .map((row) => row.id), + ) + const state = sessionIDs.length + ? Object.fromEntries( + (yield* db((db) => + db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), + )).map((row) => [row.aggregate_id, row.seq]), + ) + : {} - startSync(info) + log.info("syncing workspace history", { + workspaceID: space.id, + sessions: sessionIDs.length, + known: Object.keys(state).length, + }) - await waitEvent({ - timeout: TIMEOUT, - fn(event) { - if (event.workspace === info.id && event.payload.type === Event.Status.type) { - const { status } = event.payload.properties - return status === "error" || status === "connected" + const response = yield* http.execute( + HttpClientRequest.post(route(url, "/sync/history"), { + headers: new Headers(headers), + body: HttpBody.jsonUnsafe(state), + }), + ) + + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new SyncHttpError({ + message: `Workspace history HTTP failure: ${response.status} ${body}`, + status: response.status, + body, + }) } - return false - }, - }) - return info -}) + const events = (yield* response.json) as HistoryEvent[] -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, -}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type + log.info("workspace history synced", { + workspaceID: space.id, + events: events.length, + }) -export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => { - log.info("session restore requested", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - }) - try { - const space = await get(input.workspaceID) - if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`) - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - // Need to switch the workspace of the session - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: input.workspaceID, - }, + yield* Effect.sync(() => + WorkspaceContext.provide({ + workspaceID: space.id, + fn: () => { + for (const event of events) { + SyncEvent.replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, + ) + } + }, + }), + ) }) - const rows = Database.use((db) => - db - .select({ - id: EventTable.id, - aggregateID: EventTable.aggregate_id, - seq: EventTable.seq, - type: EventTable.type, - data: EventTable.data, - }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, input.sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) - if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`) - - const all = rows - - const size = 10 - const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size)) - const total = sets.length - log.info("session restore 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, - events: all.length, - batches: total, - first: all[0]?.seq, - last: all.at(-1)?.seq, + 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))) + + if (target.type === "local") return + + let attempt = 0 + + while (true) { + log.info("connecting to global sync", { workspace: space.name }) + setStatus(space.id, "connecting") + + const stream = yield* connectSSE(target.url, target.headers).pipe( + Effect.tap(() => syncHistory(space, target.url, target.headers)), + Effect.catch((err) => + Effect.sync(() => { + setStatus(space.id, "error") + log.info("failed to connect to global sync", { + workspace: space.name, + err, + }) + return null + }), + ), + ) + + if (stream) { + attempt = 0 + + log.info("global sync connected", { workspace: space.name }) + setStatus(space.id, "connected") + + yield* parseSSE(stream, (evt) => + Effect.sync(() => { + try { + if (!evt || typeof evt !== "object" || !("payload" in evt)) return + const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } + if (payload.type === "server.heartbeat") return + + if (payload.type === "sync" && payload.syncEvent) { + SyncEvent.replay(payload.syncEvent) + } + + const event = evt as { directory?: string; project?: string; payload: unknown } + GlobalBus.emit("event", { + directory: event.directory, + project: event.project, + workspace: space.id, + payload: event.payload, + }) + } catch (err) { + log.info("failed to replay global event", { + workspaceID: space.id, + error: err, + }) + } + }), + ) + + log.info("disconnected from global sync: " + space.id) + setStatus(space.id, "disconnected") + } + + // Back off reconnect attempts up to 2 minutes while the workspace + // stays unavailable. + yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`) + attempt += 1 + } }) - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, + + 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))) + + if (target.type === "local") { + setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") + return + } + + const exists = yield* FiberMap.has(syncFibers, space.id) + if (exists && connections.get(space.id)?.status !== "error") return + + setStatus(space.id, "disconnected") + + yield* FiberMap.run( + syncFibers, + space.id, + // TODO: look into `tapError` to set the status but still + // allow the fiber to fail and automatically get removed + syncWorkspaceLoop(space).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(space.id, "error") + log.warn("workspace listener failed", { + workspaceID: space.id, + error, + }) + }), + ), + ), + ) }) - 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, + + const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) { + yield* FiberMap.remove(syncFibers, id) + connections.delete(id) + }) + + const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { + const id = WorkspaceID.ascending(input.id) + const adaptor = getAdaptor(input.projectID, input.type) + const config = yield* Effect.promise(() => + Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })), + ) + + const info: Info = { + id, + type: config.type, + branch: config.branch ?? null, + name: config.name ?? null, + directory: config.directory ?? null, + extra: config.extra ?? null, + projectID: input.projectID, + } + + yield* db((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + }) + .run() }) - if (target.type === "local") { - SyncEvent.replayAll(events) - log.info("session restore batch replayed locally", { + + const env = { + OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()), + OPENCODE_WORKSPACE_ID: config.id, + OPENCODE_EXPERIMENTAL_WORKSPACES: "true", + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, + } + + yield* Effect.promise(() => adaptor.create(config, env)) + yield* Effect.all( + [ + waitEvent({ + timeout: TIMEOUT, + fn(event) { + if (event.workspace === info.id && event.payload.type === Event.Status.type) { + const { status } = event.payload.properties + return status === "error" || status === "connected" + } + return false + }, + }), + startSync(info), + ], + { concurrency: 2, discard: true }, + ) + + return info + }) + + const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + return yield* Effect.gen(function* () { + log.info("session restore requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, }) - } else { - const url = route(target.url, "/sync/replay") - const headers = new Headers(target.headers) - headers.set("content-type", "application/json") - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - directory: space.directory ?? "", - events, + + const space = yield* get(input.workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${input.workspaceID}`, + workspaceID: input.workspaceID, + }) + + const adaptor = getAdaptor(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, }), + ) + + const rows = yield* db((db) => + db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all(), + ) + if (rows.length === 0) + return yield* new SessionEventsNotFoundError({ + message: `No events found for session: ${input.sessionID}`, + 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 + + log.info("session restore 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, + events: rows.length, + batches: total, + first: rows[0]?.seq, + last: rows.at(-1)?.seq, }) - if (!res.ok) { - const body = await res.text() - log.error("session restore batch failed", { + + 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, - status: res.status, - body, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, }) - throw new Error( - `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, + + if (target.type === "local") { + SyncEvent.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, + }), + }), + ) + + if (res.status < 200 || res.status >= 300) { + const body = yield* res.text + log.error("session restore batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: res.status, + body, + }) + 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, + }, + }, + }), ) } - log.info("session restore batch posted", { + + log.info("session restore complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, + batches: total, }) - } - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, - }) - } - log.info("session restore complete", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - batches: total, + return { total } + }).pipe( + Effect.tapError((err) => + Effect.sync(() => + log.error("session restore failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + error: errorData(err), + }), + ), + ), + ) }) - return { - total, - } - } catch (err) { - log.error("session restore failed", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - error: errorData(err), + const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { + return yield* db((db) => + db + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, project.id)) + .all() + .map(fromRow) + .sort((a, b) => a.id.localeCompare(b.id)), + ) }) - throw err - } -}) -export function list(project: Project.Info) { - const rows = Database.use((db) => - db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), - ) - const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - return spaces -} + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { + const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + return fromRow(row) + }) -export const get = fn(WorkspaceID.zod, async (id) => { - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) - if (!row) return - return fromRow(row) -}) + const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) { + const sessions = yield* db((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), + ) + yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true }) + + const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + + yield* stopSync(id) + + 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))) + }), + () => + Effect.sync(() => { + log.error("adaptor not available when removing workspace", { type: row.type }) + }), + ) -export const remove = fn(WorkspaceID.zod, async (id) => { - const sessions = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(), - ) - for (const session of sessions) { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id))) - } + yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) + return info + }) - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + const status = Effect.fn("Workspace.status")(function* () { + return [...connections.values()] + }) - if (row) { - stopSync(id) + const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) { + const exists = yield* FiberMap.has(syncFibers, workspaceID) + return exists && connections.get(workspaceID)?.status !== "error" + }) - const info = fromRow(row) - try { - const adaptor = await getAdaptor(info.projectID, row.type) - await adaptor.remove(info) - } catch { - log.error("adaptor not available when removing workspace", { type: row.type }) - } - Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) - return info - } -}) + const waitForSync = Effect.fn("Workspace.waitForSync")(function* ( + workspaceID: WorkspaceID, + state: Record, + signal?: AbortSignal, + ) { + if (synced(state)) return + + yield* Effect.catch( + waitEvent({ + timeout: TIMEOUT, + signal, + fn(event) { + if (event.workspace !== workspaceID && event.payload.type !== "sync") { + return false + } + return synced(state) + }, + }), + (): Effect.Effect => + signal?.aborted + ? Effect.fail( + new SyncAbortedError({ + message: signal.reason instanceof Error ? signal.reason.message : "Request aborted", + cause: signal.reason, + }), + ) + : Effect.fail( + new SyncTimeoutError({ + message: `Timed out waiting for sync fence: ${JSON.stringify(state)}`, + state, + }), + ), + ) + }) -const connections = new Map() -const aborts = new Map() -const TIMEOUT = 5000 + const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { + // This session table join makes this query only return + // workspaces that have sessions + const rows = yield* db((db) => + db + .selectDistinct({ workspace: WorkspaceTable }) + .from(WorkspaceTable) + .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) + .where(eq(WorkspaceTable.project_id, projectID)) + .all(), + ) -function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) { - const prev = connections.get(id) - if (prev?.status === status) return - const next = { workspaceID: id, status } - connections.set(id, next) + for (const { workspace } of rows) { + yield* startSync(fromRow(workspace)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(workspace.id, "error") + log.warn("workspace sync failed to start", { + workspaceID: workspace.id, + error, + }) + }), + ), + Effect.forkDetach, + ) + } + }) - if (status === "error") { - aborts.delete(id) - } + return Service.of({ + create, + sessionRestore, + list, + get, + remove, + status, + isSyncing, + waitForSync, + startWorkspaceSyncing, + }) + }), +) - GlobalBus.emit("event", { - directory: "global", - workspace: id, - payload: { - type: Event.Status.type, - properties: next, - }, - }) -} +export const defaultLayer = layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(FetchHttpClient.layer), +) -export function status(): ConnectionStatus[] { - return [...connections.values()] +const TIMEOUT = 5000 + +type HistoryEvent = { + id: string + aggregate_id: string + seq: number + type: string + data: Record } function synced(state: Record) { @@ -389,32 +846,6 @@ function synced(state: Record) { }) } -export async function isSyncing(workspaceID: WorkspaceID) { - return aborts.has(workspaceID) -} - -export async function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { - if (synced(state)) return - - try { - await waitEvent({ - timeout: TIMEOUT, - signal, - fn(event) { - if (event.workspace !== workspaceID && event.payload.type !== "sync") { - return false - } - return synced(state) - }, - }) - } catch { - if (signal?.aborted) throw signal.reason ?? new Error("Request aborted") - throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`) - } -} - -const log = Log.create({ service: "workspace-sync" }) - function route(url: string | URL, path: string) { const next = new URL(url) next.pathname = `${next.pathname.replace(/\/$/, "")}${path}` @@ -423,198 +854,42 @@ function route(url: string | URL, path: string) { return next } -async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { - const res = await fetch(route(url, "/global/event"), { - method: "GET", - headers, - signal, - }) +const { runPromise, runSync } = makeRuntime(Service, defaultLayer) - if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`) - if (!res.body) throw new Error("No response body from global sync") +export const create = fn(CreateInput.zod, (input) => runPromise((svc) => svc.create(input))) - return res.body -} +export const sessionRestore = fn(SessionRestoreInput.zod, (input) => runPromise((svc) => svc.sessionRestore(input))) -async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) { - const sessionIDs = Database.use((db) => +export function list(project: Project.Info) { + return Database.use((db) => db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(eq(SessionTable.workspace_id, space.id)) + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.project_id, project.id)) .all() - .map((row) => row.id), + .map(fromRow) + .sort((a, b) => a.id.localeCompare(b.id)), ) - const state = sessionIDs.length - ? Object.fromEntries( - Database.use((db) => - db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(), - ).map((row) => [row.aggregate_id, row.seq]), - ) - : {} - - log.info("syncing workspace history", { - workspaceID: space.id, - sessions: sessionIDs.length, - known: Object.keys(state).length, - }) - - const requestHeaders = new Headers(headers) - requestHeaders.set("content-type", "application/json") - - const res = await fetch(route(url, "/sync/history"), { - method: "POST", - headers: requestHeaders, - body: JSON.stringify(state), - signal, - }) - - if (!res.ok) { - const body = await res.text() - throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`) - } - - const events = await res.json() - - return WorkspaceContext.provide({ - workspaceID: space.id, - fn: () => { - for (const event of events) { - SyncEvent.replay( - { - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }, - { publish: true }, - ) - } - }, - }) - - log.info("workspace history synced", { - workspaceID: space.id, - events: events.length, - }) } -async function syncWorkspaceLoop(space: Info, signal: AbortSignal) { - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") return null - - let attempt = 0 - - while (!signal.aborted) { - log.info("connecting to global sync", { workspace: space.name }) - setStatus(space.id, "connecting") - - let stream - try { - stream = await connectSSE(target.url, target.headers, signal) - await syncHistory(space, target.url, target.headers, signal) - } catch (err) { - stream = null - setStatus(space.id, "error") - log.info("failed to connect to global sync", { - workspace: space.name, - err, - }) - } - - if (stream) { - attempt = 0 +export const get = fn(WorkspaceID.zod, (id) => runPromise((svc) => svc.get(id))) - log.info("global sync connected", { workspace: space.name }) - setStatus(space.id, "connected") +export const remove = fn(WorkspaceID.zod, (id) => runPromise((svc) => svc.remove(id))) - await parseSSE(stream, signal, (evt: any) => { - try { - if (!("payload" in evt)) return - if (evt.payload.type === "server.heartbeat") return - - if (evt.payload.type === "sync") { - SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent) - } - - GlobalBus.emit("event", { - directory: evt.directory, - project: evt.project, - workspace: space.id, - payload: evt.payload, - }) - } catch (err) { - log.info("failed to replay global event", { - workspaceID: space.id, - error: err, - }) - } - }) - - log.info("disconnected from global sync: " + space.id) - setStatus(space.id, "disconnected") - } - - // Back off reconnect attempts up to 2 minutes while the workspace - // stays unavailable. - await sleep(Math.min(120_000, 1_000 * 2 ** attempt)) - attempt += 1 - } +export function status() { + return runSync((svc) => svc.status()) } -async function startSync(space: Info) { - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - - const adaptor = await getAdaptor(space.projectID, space.type) - const target = await adaptor.target(space) - - if (target.type === "local") { - void Filesystem.exists(target.directory).then((exists) => { - setStatus(space.id, exists ? "connected" : "error") - }) - return - } - - if (aborts.has(space.id)) return true - - setStatus(space.id, "disconnected") - - const abort = new AbortController() - aborts.set(space.id, abort) - - void syncWorkspaceLoop(space, abort.signal).catch((error) => { - aborts.delete(space.id) - - setStatus(space.id, "error") - log.warn("workspace listener failed", { - workspaceID: space.id, - error, - }) - }) +export function isSyncing(workspaceID: WorkspaceID) { + return runSync((svc) => svc.isSyncing(workspaceID)) } -function stopSync(id: WorkspaceID) { - aborts.get(id)?.abort() - aborts.delete(id) - connections.delete(id) +export function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { + return runPromise((svc) => svc.waitForSync(workspaceID, state, signal)) } export function startWorkspaceSyncing(projectID: ProjectID) { - const spaces = Database.use((db) => - db - .select({ workspace: WorkspaceTable }) - .from(WorkspaceTable) - .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) - .where(eq(WorkspaceTable.project_id, projectID)) - .all(), - ) - - for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) { - void startSync(fromRow(row)) - } + void runPromise((svc) => svc.startWorkspaceSyncing(projectID)) } export * as Workspace from "./workspace" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index fdd30536222d..84be1706888a 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -41,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" import { Installation } from "@/installation" @@ -90,6 +91,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Workspace.defaultLayer, Worktree.defaultLayer, Pty.defaultLayer, Installation.defaultLayer, 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 9318dbfe5a66..68dc0b9d7fd5 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 @@ -89,7 +89,7 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe function resolveTarget(workspace: Workspace.Info): Effect.Effect { return Effect.gen(function* () { - const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type)) + const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type)) return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) }) } @@ -101,7 +101,7 @@ function proxyRemote( url: URL, ): Effect.Effect { return Effect.gen(function* () { - const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id)) + const syncing = yield* Effect.sync(() => Workspace.isSyncing(workspace.id)) if (!syncing) { return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { status: 503, diff --git a/packages/opencode/test/control-plane/sse.test.ts b/packages/opencode/test/control-plane/sse.test.ts deleted file mode 100644 index 78a8341c0e89..000000000000 --- a/packages/opencode/test/control-plane/sse.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { parseSSE } from "../../src/control-plane/sse" -import { resetDatabase } from "../fixture/db" - -afterEach(async () => { - await resetDatabase() -}) - -function stream(chunks: string[]) { - return new ReadableStream({ - start(controller) { - const encoder = new TextEncoder() - chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))) - controller.close() - }, - }) -} - -describe("control-plane/sse", () => { - test("parses JSON events with CRLF and multiline data blocks", async () => { - const events: unknown[] = [] - const stop = new AbortController() - - await parseSSE( - stream([ - 'data: {"type":"one","properties":{"ok":true}}\r\n\r\n', - 'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n', - ]), - stop.signal, - (event) => events.push(event), - ) - - expect(events).toEqual([ - { type: "one", properties: { ok: true } }, - { type: "two", properties: { n: 2 } }, - ]) - }) - - test("falls back to sse.message for non-json payload", async () => { - const events: unknown[] = [] - const stop = new AbortController() - - await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event)) - - expect(events).toEqual([ - { - type: "sse.message", - properties: { - data: "hello world", - id: "abc", - retry: 1500, - }, - }, - ]) - }) -}) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts new file mode 100644 index 000000000000..c94d3f9a3285 --- /dev/null +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -0,0 +1,1391 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import fs from "node:fs/promises" +import Http from "node:http" +import path from "node:path" +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 * as Log from "@opencode-ai/core/util/log" +import { Flag } from "@opencode-ai/core/flag/flag" +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Database } from "@/storage/db" +import { ProjectID } from "@/project/schema" +import { ProjectTable } from "@/project/project.sql" +import { Instance } from "@/project/instance" +import { Session as SessionNs } from "@/session/session" +import { SessionID, MessageID, PartID } 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 { resetDatabase } from "../fixture/db" +import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { registerAdaptor } from "../../src/control-plane/adaptors" +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 * as WorkspaceOld from "../../src/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" + +void Log.init({ print: false }) + +const testServerLayer = Layer.mergeAll( + NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + WorkspaceOld.defaultLayer, + SessionNs.defaultLayer, +) +const it = testEffect(testServerLayer) + +const originalWorkspacesFlag = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const originalEnv = { + OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT, + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, +} + +type RecordedCreate = { + info: WorkspaceInfo + env: Record + from?: WorkspaceInfo +} + +type RecordedAdaptor = { + adaptor: WorkspaceAdaptor + calls: { + configure: WorkspaceInfo[] + create: RecordedCreate[] + remove: WorkspaceInfo[] + target: WorkspaceInfo[] + } +} + +type FetchCall = { + url: URL + method: string + headers: Headers + bodyText?: string + json?: unknown +} + +function unique(prefix: string) { + return `${prefix}-${Math.random().toString(36).slice(2)}` +} + +function restoreEnv() { + Object.entries(originalEnv).forEach(([key, value]) => { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value + }) +} + +beforeEach(() => { + Database.close() + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + restoreEnv() +}) + +afterEach(async () => { + mock.restore() + await Instance.disposeAll() + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag + restoreEnv() + await resetDatabase() +}) + +async function withInstance(fn: (dir: string) => T | Promise) { + await using tmp = await tmpdir({ git: true }) + return Instance.provide({ + directory: tmp.path, + fn: () => fn(tmp.path), + }) +} + +function captureGlobalEvents() { + const events: GlobalEvent[] = [] + const handler = (event: GlobalEvent) => events.push(event) + GlobalBus.on("event", handler) + return { + events, + dispose() { + GlobalBus.off("event", handler) + }, + } +} + +async function eventually(fn: () => T | Promise, timeout = 1500) { + const started = Date.now() + let last: unknown + while (Date.now() - started < timeout) { + try { + return await fn() + } catch (err) { + last = err + await delay(10) + } + } + throw last ?? new Error("Timed out waiting for condition") +} + +function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { + return Effect.gen(function* () { + const started = Date.now() + let last: unknown + while (Date.now() - started < timeout) { + const exit = yield* Effect.exit(effect) + if (exit._tag === "Success") return + last = exit.cause + yield* Effect.sleep("10 millis") + } + throw last ?? new Error("Timed out waiting for condition") + }) +} + +function recordedAdaptor(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"] = { + configure: [], + create: [], + remove: [], + target: [], + } + + return { + calls, + adaptor: { + name: "recorded", + description: "recorded", + configure(info) { + calls.configure.push(structuredClone(info)) + return input.configure?.(info) ?? info + }, + async create(info, env, from) { + calls.create.push({ info: structuredClone(info), env: { ...env }, from: from ? structuredClone(from) : undefined }) + await input.create?.(info, env, from) + }, + async remove(info) { + calls.remove.push(structuredClone(info)) + await input.remove?.(info) + }, + target(info) { + calls.target.push(structuredClone(info)) + return input.target(info) + }, + }, + } +} + +function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { + return recordedAdaptor({ + configure(info) { + return { ...info, directory: dir } + }, + async create() { + if (input?.createDir === false) return + await fs.mkdir(dir, { recursive: true }) + }, + remove: input?.remove, + target() { + return { type: "local", directory: dir } + }, + }) +} + +function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { + return recordedAdaptor({ + configure(info) { + return { ...info, directory: input?.directory ?? info.directory } + }, + target() { + return { type: "remote", url, headers: input?.headers } + }, + }) +} + +function eventStreamResponse(events: unknown[] = [], keepOpen = true) { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + if (keepOpen) controller.enqueue(encoder.encode(":\n\n")) + events.forEach((event) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))) + if (!keepOpen) controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) +} + +function serverUrl() { + return Effect.gen(function* () { + return HttpServer.formatAddress((yield* HttpServer.HttpServer).address) + }) +} + +function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): WorkspaceInfo { + return { + id: input?.id ?? WorkspaceID.ascending(), + type, + name: input?.name ?? unique("workspace"), + branch: input?.branch ?? null, + directory: input?.directory ?? null, + extra: input?.extra ?? null, + projectID, + } +} + +function insertWorkspace(info: WorkspaceInfo) { + Database.use((db) => + db + .insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + }) + .run(), + ) +} + +function insertProject(id: ProjectID, worktree: string) { + Database.use((db) => + db + .insert(ProjectTable) + .values({ + id, + worktree, + vcs: null, + name: null, + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [], + }) + .run(), + ) +} + +function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) { + Database.use((db) => db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run()) +} + +function sessionSequence(sessionID: SessionID) { + return Database.use((db) => + db.select({ seq: EventSequenceTable.seq }).from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).get(), + )?.seq +} + +function eventRows(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(), + ) +} + +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") + }) + + test("validates create input with workspace id, project id, branch, type, and extra", () => { + const input = { + id: WorkspaceID.ascending("wrk_schema_create"), + type: "worktree", + branch: "feature/schema", + projectID: ProjectID.make("project-schema"), + extra: { nested: true }, + } + + expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input) + 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", () => { + test("get returns undefined for a missing workspace", async () => { + await withInstance(async () => { + expect(await WorkspaceOld.get(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() + }) + }) + + test("list maps database rows, filters by project, and sorts by id", async () => { + await withInstance(() => { + const otherProjectID = ProjectID.make("project-other") + insertProject(otherProjectID, "/tmp/other") + const a = workspaceInfo(Instance.project.id, "manual", { + id: WorkspaceID.ascending("wrk_a_list"), + branch: "a", + directory: "/a", + extra: { a: true }, + }) + const b = workspaceInfo(Instance.project.id, "manual", { + id: WorkspaceID.ascending("wrk_b_list"), + branch: "b", + directory: "/b", + extra: ["b"], + }) + const other = workspaceInfo(otherProjectID, "manual", { id: WorkspaceID.ascending("wrk_c_list") }) + insertWorkspace(b) + insertWorkspace(other) + insertWorkspace(a) + + expect(WorkspaceOld.list(Instance.project)).toEqual([a, b]) + }) + }) + + test("create configures, persists, creates, starts local sync, and passes environment", async () => { + await withInstance(async (dir) => { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ test: { type: "api", key: "secret" } }) + process.env.OTEL_EXPORTER_OTLP_HEADERS = "authorization=otel" + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.test" + process.env.OTEL_RESOURCE_ATTRIBUTES = "service.name=opencode-test" + + const workspaceID = WorkspaceID.ascending("wrk_create_local") + const type = unique("create-local") + const targetDir = path.join(dir, "created-local") + const recorded = recordedAdaptor({ + configure(info) { + return { + ...info, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + } + }, + async create() { + await fs.mkdir(targetDir, { recursive: true }) + }, + target() { + return { type: "local", directory: targetDir } + }, + }) + registerAdaptor(Instance.project.id, type, recorded.adaptor) + + const info = await WorkspaceOld.create({ + id: workspaceID, + type, + branch: null, + projectID: Instance.project.id, + extra: null, + }) + + expect(info).toEqual({ + id: workspaceID, + type, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + projectID: Instance.project.id, + }) + expect(await WorkspaceOld.get(workspaceID)).toEqual(info) + expect(WorkspaceOld.list(Instance.project)).toEqual([info]) + expect(recorded.calls.configure).toHaveLength(1) + expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) + expect(recorded.calls.create).toHaveLength(1) + expect(recorded.calls.create[0].info).toEqual(info) + expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({ + test: { type: "api", key: "secret" }, + }) + expect(recorded.calls.create[0].env.OPENCODE_WORKSPACE_ID).toBe(workspaceID) + expect(recorded.calls.create[0].env.OPENCODE_EXPERIMENTAL_WORKSPACES).toBe("true") + expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_HEADERS).toBe("authorization=otel") + expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.test") + expect(recorded.calls.create[0].env.OTEL_RESOURCE_ATTRIBUTES).toBe("service.name=opencode-test") + expect(WorkspaceOld.status().find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") + + await WorkspaceOld.remove(workspaceID) + expect(WorkspaceOld.status().find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined() + }) + }) + + test("create propagates configure failures and does not insert a workspace", async () => { + await withInstance(async () => { + const type = unique("configure-failure") + registerAdaptor( + Instance.project.id, + type, + recordedAdaptor({ + configure() { + throw new Error("configure exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }).adaptor, + ) + + await expect( + WorkspaceOld.create({ type, branch: null, projectID: Instance.project.id, extra: null }), + ).rejects.toThrow("configure exploded") + expect(WorkspaceOld.list(Instance.project)).toEqual([]) + }) + }) + + test("create leaves the inserted row when adaptor create fails", async () => { + await withInstance(async () => { + const type = unique("create-failure") + const recorded = recordedAdaptor({ + async create() { + throw new Error("create exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }) + registerAdaptor(Instance.project.id, type, recorded.adaptor) + + await expect( + WorkspaceOld.create({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), + ).rejects.toThrow("create exploded") + + const rows = WorkspaceOld.list(Instance.project) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } }) + expect(recorded.calls.target).toHaveLength(0) + await WorkspaceOld.remove(rows[0].id) + }) + }) + + test("create returns after a local workspace reports error", async () => { + 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 info = await WorkspaceOld.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + + expect(info.directory).toBe(missing) + expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error") + await WorkspaceOld.remove(info.id) + }) + }) + + it.live("remote create connects to routed event and history endpoints", () => { + const calls: 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, + } + calls.push(call) + if (call.url.pathname === "/base/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([]) + return HttpServerResponse.text("unexpected", { status: 500 }) + })) + const url = yield* serverUrl() + yield* provideTmpdirInstance((dir) => + 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 info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`)).toEqual([ + "GET /base/global/event", + "POST /base/sync/history", + ]) + expect(calls[1].json).toEqual({}) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + }), + { git: true }, + ) + }) + }) + + test("remove returns undefined for a missing workspace", async () => { + await withInstance(async () => { + expect(await WorkspaceOld.remove(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined() + }) + }) + + test("remove deletes the workspace, associated sessions, adaptor 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 info = await WorkspaceOld.create({ 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({}))) + attachSessionToWorkspace(one.id, info.id) + attachSessionToWorkspace(two.id, info.id) + + const removed = await WorkspaceOld.remove(info.id) + + expect(removed).toEqual(info) + expect(await WorkspaceOld.get(info.id)).toBeUndefined() + expect(recorded.calls.remove).toEqual([info]) + expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + expect( + Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(), + ), + ).toEqual([]) + }) + }) + + test("remove still deletes the row when the adaptor 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( + Instance.project.id, + type, + recordedAdaptor({ + async remove() { + throw new Error("remove exploded") + }, + target() { + return { type: "local", directory: "/unused" } + }, + }).adaptor, + ) + insertWorkspace(info) + + expect(await WorkspaceOld.remove(info.id)).toEqual(info) + expect(await WorkspaceOld.get(info.id)).toBeUndefined() + }) + }) +}) + +describe("workspace-old sync state", () => { + test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { + await withInstance(async (dir) => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + const type = unique("flag-disabled") + const info = workspaceInfo(Instance.project.id, type) + 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) + + WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + await delay(25) + + expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + }) + }) + + test("startWorkspaceSyncing starts only workspaces with sessions", async () => { + await withInstance(async (dir) => { + const withSessionType = unique("with-session") + const withoutSessionType = unique("without-session") + const withSession = workspaceInfo(Instance.project.id, withSessionType) + const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType) + const withSessionDir = path.join(dir, "with-session") + const withoutSessionDir = path.join(dir, "without-session") + await fs.mkdir(withSessionDir, { recursive: true }) + 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) + attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id) + + WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + + await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected")) + expect(WorkspaceOld.status().find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() + await WorkspaceOld.remove(withSession.id) + await WorkspaceOld.remove(withoutSession.id) + }) + }) + + test("local start reports error when the target directory is missing", async () => { + await withInstance(async (dir) => { + const type = unique("missing-local") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor) + attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id) + + WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + + await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error")) + expect(await WorkspaceOld.isSyncing(info.id)).toBe(false) + await WorkspaceOld.remove(info.id) + }) + }) + + test("duplicate local status updates are suppressed", async () => { + await withInstance(async (dir) => { + const captured = captureGlobalEvents() + try { + const type = unique("dedupe-local") + const info = workspaceInfo(Instance.project.id, type) + const target = path.join(dir, "dedupe-local") + await fs.mkdir(target, { recursive: true }) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor) + attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id) + + WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + + await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected")) + expect( + captured.events.filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + ), + ).toHaveLength(1) + await WorkspaceOld.remove(info.id) + } finally { + captured.dispose() + } + }) + }) + + it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => { + const calls: 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, + } + calls.push(call) + if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("remote-start") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* eventuallyEffect(Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") + })) + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* Effect.sleep("25 millis") + + expect( + captured.events + .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type) + .map((event) => event.payload.properties.status), + ).toEqual(["disconnected", "connecting", "connected"]) + expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) + expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1) + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("remote connection HTTP failures set error and clear syncing", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()(Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + if (new URL(req.url, "http://localhost").pathname === "/failed/global/event") + return HttpServerResponse.text("nope", { status: 503 }) + return HttpServerResponse.fromWeb(Response.json([])) + })) + const url = yield* serverUrl() + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("remote-connect-fail") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect(Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + })) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }), + ) + + it.live("remote history HTTP failures set error", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()(Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/history-failed/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (url.pathname === "/history-failed/sync/history") return HttpServerResponse.text("history failed", { status: 500 }) + return HttpServerResponse.fromWeb(Response.json([])) + })) + const url = yield* serverUrl() + yield* provideTmpdirInstance(() => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + 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) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect(Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + })) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + }), + ) + + it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => { + const historyBodies: unknown[] = [] + 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 url = new URL(req.url, "http://localhost") + if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (url.pathname === "/history/sync/history") { + historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined) + return HttpServerResponse.fromWeb( + Response.json([ + { + id: `evt_${unique("history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from history" } }, + }, + ]), + ) + } + 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 captured = captureGlobalEvents() + try { + const type = unique("history-replay") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) + const session = yield* sessionSvc.create({ title: "before history" }) + attachSessionToWorkspace(session.id, info.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect(Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from history") + })) + expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) + expect( + captured.events.some( + (event) => + event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === historyNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) + + it.live("SSE forwards non-heartbeat events and ignores heartbeats", () => + Effect.gen(function* () { + yield* HttpServer.serveEffect()(Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-forward/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } }, + { + directory: "remote-dir", + project: "remote-project", + payload: { type: "custom.remote", properties: { ok: true } }, + }, + ], + false, + ), + ) + if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("sse-forward") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect(Effect.sync(() => + expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "custom.remote")) + .toBe(true), + )) + expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "server.heartbeat")).toBe( + false, + ) + expect( + captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"), + ).toMatchObject({ directory: "remote-dir", project: "remote-project", payload: { properties: { ok: true } } }) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }), + ) + + it.live("SSE sync events are replayed and forwarded", () => { + let sseSessionID: SessionID | undefined + let sseNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()(Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-sync/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { + directory: "remote-dir", + project: "remote-project", + payload: { + type: "sync", + syncEvent: { + id: `evt_${unique("sse")}`, + aggregateID: sseSessionID!, + seq: sseNextSeq, + type: sessionUpdatedType(), + data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + }, + }, + }, + ], + false, + ), + ) + if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("sse-sync") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) + const session = yield* sessionSvc.create({ title: "before sse" }) + attachSessionToWorkspace(session.id, info.id) + sseSessionID = session.id + sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect(Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") + })) + expect( + captured.events.some( + (event) => event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === sseNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), + { git: true }, + ) + }) + }) +}) + +describe("workspace-old waitForSync", () => { + test("returns immediately for an empty fence", async () => { + await withInstance(async () => { + await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() + }) + }) + + test("returns immediately when the stored sequence already satisfies the fence", async () => { + await withInstance(async () => { + const sessionID = SessionID.descending("ses_wait_done") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run()) + + await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).resolves.toBeUndefined() + await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 })).resolves.toBeUndefined() + }) + }) + + test("waits until the database reaches the requested sequence and a workspace event arrives", async () => { + await withInstance(async () => { + const workspaceID = WorkspaceID.ascending("wrk_wait_event") + const sessionID = SessionID.descending("ses_wait_event") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run()) + + const waited = WorkspaceOld.waitForSync(workspaceID, { [sessionID]: 2 }) + await delay(10) + Database.use((db) => + db.update(EventSequenceTable).set({ seq: 2 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), + ) + GlobalBus.emit("event", { workspace: workspaceID, payload: { type: "anything" } }) + + await expect(waited).resolves.toBeUndefined() + }) + }) + + test("a sync event for a different workspace can also release the fence", async () => { + await withInstance(async () => { + const workspaceID = WorkspaceID.ascending("wrk_wait_sync_any") + const sessionID = SessionID.descending("ses_wait_sync_any") + Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run()) + + const waited = WorkspaceOld.waitForSync(workspaceID, { [sessionID]: 1 }) + await delay(10) + Database.use((db) => + db.update(EventSequenceTable).set({ seq: 1 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), + ) + GlobalBus.emit("event", { + workspace: WorkspaceID.ascending("wrk_other_workspace"), + payload: { type: "sync" }, + }) + + await expect(waited).resolves.toBeUndefined() + }) + }) + + test("rejects with the abort reason when aborted", async () => { + await withInstance(async () => { + const abort = new AbortController() + const reason = new Error("caller aborted") + const waited = WorkspaceOld.waitForSync( + WorkspaceID.ascending("wrk_wait_abort"), + { [SessionID.descending("ses_wait_abort")]: 1 }, + abort.signal, + ) + abort.abort(reason) + + await expect(waited).rejects.toMatchObject({ _tag: "WorkspaceSyncAbortedError", message: reason.message, cause: reason }) + }) + }) + + test( + "times out with the requested fence in the error message", + async () => { + await withInstance(async () => { + const sessionID = SessionID.descending("ses_wait_timeout") + + await expect( + WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }), + ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`) + }) + }, + 7000, + ) +}) + +describe("workspace-old sessionRestore", () => { + test("throws when the workspace is missing", async () => { + await withInstance(async () => { + await expect( + WorkspaceOld.sessionRestore({ + 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) + registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + + await expect( + WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), + ).rejects.toThrow("NotFoundError") + await WorkspaceOld.remove(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) + registerAdaptor( + Instance.project.id, + type, + remoteAdaptor(`${url}/restore/?ignored=1#hash`, { + directory: dir, + headers: { authorization: "Bearer restore" }, + }).adaptor, + ) + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) + 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 }, + ) + }) + }) + + test("local restore replays batches without fetch and emits progress", async () => { + await withInstance(async (dir) => { + const captured = captureGlobalEvents() + let fetchCallCount = 0 + const replayAll = spyOn(SyncEvent, "replayAll") + try { + using server = Bun.serve({ + port: 0, + fetch() { + fetchCallCount++ + return Response.json({ ok: true }) + }, + }) + const type = unique("restore-local") + const info = workspaceInfo(Instance.project.id, type, { directory: dir }) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({ title: "restore local" }))) + replaceSessionEvents(session.id, 20) + + expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) + + expect(fetchCallCount).toBe(0) + expect(replayAll).toHaveBeenCalledTimes(3) + expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1]) + expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.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]) + await WorkspaceOld.remove(info.id) + } finally { + captured.dispose() + } + }) + }) + + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) + 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/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts deleted file mode 100644 index 7f802150ea40..000000000000 --- a/packages/opencode/test/workspace/workspace-restore.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" -import fs from "node:fs/promises" -import path from "node:path" -import { GlobalBus } from "../../src/bus/global" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" -import { Workspace } from "../../src/control-plane/workspace" -import { AppRuntime } from "../../src/effect/app-runtime" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" -import { Session as SessionNs } from "@/session/session" -import { MessageV2 } from "../../src/session/message-v2" -import { MessageID, PartID, type SessionID } from "../../src/session/schema" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { SyncEvent } from "../../src/sync" -import { EventTable } from "../../src/sync/event.sql" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - -beforeEach(() => { - Database.close() - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true -}) - -afterEach(async () => { - mock.restore() - await Instance.disposeAll() - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original - await resetDatabase() -}) - -function create(input?: SessionNs.CreateInput) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input))) -} - -function get(id: SessionID) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id))) -} - -function updateMessage(msg: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg))) -} - -function updatePart(part: T) { - return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part))) -} - -async function user(sessionID: SessionID, text: string) { - const msg = await updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - await updatePart({ - id: PartID.ascending(), - sessionID, - messageID: msg.id, - type: "text", - text, - }) -} - -function remote(dir: string, url: string): WorkspaceAdaptor { - return { - name: "remote", - description: "remote", - configure(info) { - return { - ...info, - directory: dir, - } - }, - async create() { - await fs.mkdir(dir, { recursive: true }) - }, - async remove() {}, - target() { - return { - type: "remote" as const, - url, - } - }, - } -} - -function local(dir: string): WorkspaceAdaptor { - return { - name: "local", - description: "local", - configure(info) { - return { - ...info, - directory: dir, - } - }, - async create() { - await fs.mkdir(dir, { recursive: true }) - }, - async remove() {}, - target() { - return { - type: "local" as const, - directory: dir, - } - }, - } -} - -function eventStreamResponse() { - return new Response(new ReadableStream({ start() {} }), { - status: 200, - headers: { - "content-type": "text/event-stream", - }, - }) -} - -describe("Workspace.sessionRestore", () => { - test("replays session events in batches of 10 and emits progress", async () => { - await using tmp = await tmpdir({ git: true }) - const dir = path.join(tmp.path, ".restore") - const seen: any[] = [] - const posts: Array<{ - path: string - body: { directory: string; events: Array<{ seq: number; aggregateID: string }> } - }> = [] - const on = (evt: any) => seen.push(evt) - GlobalBus.on("event", on) - - const raw = globalThis.fetch - spyOn(globalThis, "fetch").mockImplementation( - Object.assign( - async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url) - if (url.pathname === "/base/global/event") { - return eventStreamResponse() - } - if (url.pathname === "/base/sync/history") { - return Response.json([]) - } - const body = JSON.parse(String(init?.body)) - posts.push({ - path: url.pathname, - body, - }) - return Response.json({ sessionID: body.events[0].aggregateID }) - }, - { - preconnect: raw.preconnect?.bind(raw), - }, - ) as typeof globalThis.fetch, - ) - - try { - const setup = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base")) - const space = await Workspace.create({ - type: "worktree", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - const session = await create({}) - for (let i = 0; i < 6; i++) { - await user(session.id, `msg ${i}`) - } - const rows = Database.use((db) => - db - .select({ seq: EventTable.seq }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, session.id)) - .orderBy(asc(EventTable.seq)) - .all(), - ) - const result = await Workspace.sessionRestore({ - workspaceID: space.id, - sessionID: session.id, - }) - return { space, session, rows, result } - }, - }) - - expect(setup.rows).toHaveLength(13) - expect(setup.result).toEqual({ total: 2 }) - expect(posts).toHaveLength(2) - expect(posts[0]?.path).toBe("/base/sync/replay") - expect(posts[1]?.path).toBe("/base/sync/replay") - expect(posts[0]?.body.directory).toBe(dir) - expect(posts[1]?.body.directory).toBe(dir) - expect(posts[0]?.body.events).toHaveLength(10) - expect(posts[1]?.body.events).toHaveLength(4) - expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([ - ...setup.rows.map((row) => row.seq), - setup.rows.at(-1)!.seq + 1, - ]) - expect(posts[1]?.body.events.at(-1)).toMatchObject({ - aggregateID: setup.session.id, - seq: setup.rows.at(-1)!.seq + 1, - type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version), - data: { - sessionID: setup.session.id, - info: { - workspaceID: setup.space.id, - }, - }, - }) - - const restore = seen.filter( - (evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type, - ) - expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2]) - expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2]) - expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([ - setup.session.id, - setup.session.id, - setup.session.id, - ]) - } finally { - GlobalBus.off("event", on) - } - }) - - test("replays locally without posting to a server", async () => { - await using tmp = await tmpdir({ git: true }) - const dir = path.join(tmp.path, ".restore-local") - const seen: any[] = [] - const on = (evt: any) => seen.push(evt) - GlobalBus.on("event", on) - - const fetch = spyOn(globalThis, "fetch") - const replayAll = spyOn(SyncEvent, "replayAll") - - try { - const setup = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor(Instance.project.id, "local-restore", local(dir)) - const space = await Workspace.create({ - type: "local-restore", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - const session = await create({}) - for (let i = 0; i < 6; i++) { - await user(session.id, `msg ${i}`) - } - const result = await Workspace.sessionRestore({ - workspaceID: space.id, - sessionID: session.id, - }) - const updated = await get(session.id) - return { space, session, result, updated } - }, - }) - - expect(setup.result).toEqual({ total: 2 }) - expect(fetch).not.toHaveBeenCalled() - expect(replayAll).toHaveBeenCalledTimes(2) - expect(setup.updated.workspaceID).toBe(setup.space.id) - - const restore = seen.filter( - (evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type, - ) - expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2]) - } finally { - GlobalBus.off("event", on) - } - }) -}) From 8f57a2a46225109fbd1f74e545e22a9378f777b4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 15:46:04 +0000 Subject: [PATCH 0038/1114] chore: generate --- .../opencode/src/control-plane/dev/README.md | 7 +- .../opencode/src/control-plane/workspace.ts | 11 +- .../test/control-plane/workspace.test.ts | 1106 +++++++++-------- packages/sdk/js/src/v2/gen/types.gen.ts | 80 +- packages/sdk/openapi.json | 232 ++-- 5 files changed, 778 insertions(+), 658 deletions(-) diff --git a/packages/opencode/src/control-plane/dev/README.md b/packages/opencode/src/control-plane/dev/README.md index dbd62c0b1feb..74d68a75a851 100644 --- a/packages/opencode/src/control-plane/dev/README.md +++ b/packages/opencode/src/control-plane/dev/README.md @@ -1,4 +1,3 @@ - This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`: ```json @@ -15,6 +14,6 @@ With the plugin install, you can now run OpenCode and create a `debug` workspace How this works: -* The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written. -* The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server. -* The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist. \ No newline at end of file +- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written. +- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server. +- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist. diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 2d8c5704419d..870bdba50038 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -101,10 +101,13 @@ export class SyncHttpError extends Schema.TaggedErrorClass()("Wor body: Schema.optional(Schema.String), }) {} -export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()("WorkspaceNotFoundError", { - message: Schema.String, - workspaceID: WorkspaceID, -}) {} +export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceNotFoundError", + { + message: Schema.String, + workspaceID: WorkspaceID, + }, +) {} export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( "WorkspaceSessionEventsNotFoundError", diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index c94d3f9a3285..bd5c4df7d54a 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -170,7 +170,11 @@ function recordedAdaptor(input: { return input.configure?.(info) ?? info }, async create(info, env, from) { - calls.create.push({ info: structuredClone(info), env: { ...env }, from: from ? structuredClone(from) : undefined }) + calls.create.push({ + info: structuredClone(info), + env: { ...env }, + from: from ? structuredClone(from) : undefined, + }) await input.create?.(info, env, from) }, async remove(info) { @@ -279,12 +283,18 @@ function insertProject(id: ProjectID, worktree: string) { } function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceID) { - Database.use((db) => db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run()) + Database.use((db) => + db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run(), + ) } function sessionSequence(sessionID: SessionID) { return Database.use((db) => - db.select({ seq: EventSequenceTable.seq }).from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).get(), + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), )?.seq } @@ -308,7 +318,9 @@ function replaceSessionEvents(sessionID: SessionID, count: number) { 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(EventSequenceTable) + .values({ aggregate_id: sessionID, seq: count - 1 }) + .run() db.insert(EventTable) .values( Array.from({ length: count }, (_, i) => ({ @@ -522,43 +534,46 @@ describe("workspace-old CRUD", () => { it.live("remote create connects to routed event and history endpoints", () => { const calls: 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, - } - calls.push(call) - if (call.url.pathname === "/base/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false)) - if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([]) - return HttpServerResponse.text("unexpected", { status: 500 }) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance((dir) => + yield* HttpServer.serveEffect()( 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 info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) - - expect(calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`)).toEqual([ - "GET /base/global/event", - "POST /base/sync/history", - ]) - expect(calls[1].json).toEqual({}) - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") - expect(yield* workspace.isSyncing(info.id)).toBe(true) - - yield* workspace.remove(info.id) - expect(yield* workspace.isSyncing(info.id)).toBe(false) - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + 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 === "/base/global/event") + return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([]) + return HttpServerResponse.text("unexpected", { status: 500 }) }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + 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 info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + + expect( + calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`), + ).toEqual(["GET /base/global/event", "POST /base/sync/history"]) + expect(calls[1].json).toEqual({}) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + }), { git: true }, ) }) @@ -651,11 +666,16 @@ describe("workspace-old sync state", () => { insertWorkspace(withoutSession) registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor) registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor) - attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + withSession.id, + ) WorkspaceOld.startWorkspaceSyncing(Instance.project.id) - await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected")) + await eventually(() => + expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), + ) expect(WorkspaceOld.status().find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() await WorkspaceOld.remove(withSession.id) await WorkspaceOld.remove(withoutSession.id) @@ -667,12 +687,21 @@ describe("workspace-old sync state", () => { const type = unique("missing-local") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor) - attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id) + registerAdaptor( + Instance.project.id, + type, + localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor, + ) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + info.id, + ) WorkspaceOld.startWorkspaceSyncing(Instance.project.id) - await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error")) + await eventually(() => + expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error"), + ) expect(await WorkspaceOld.isSyncing(info.id)).toBe(false) await WorkspaceOld.remove(info.id) }) @@ -688,12 +717,17 @@ describe("workspace-old sync state", () => { await fs.mkdir(target, { recursive: true }) insertWorkspace(info) registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor) - attachSessionToWorkspace((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id) + attachSessionToWorkspace( + (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, + info.id, + ) WorkspaceOld.startWorkspaceSyncing(Instance.project.id) WorkspaceOld.startWorkspaceSyncing(Instance.project.id) - await eventually(() => expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected")) + await eventually(() => + expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected"), + ) expect( captured.events.filter( (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, @@ -709,56 +743,65 @@ describe("workspace-old sync state", () => { it.live("remote start emits disconnected, connecting, and connected then refuses duplicate listeners", () => { const calls: 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, - } - calls.push(call) - if (call.url.pathname === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) - if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) - return HttpServerResponse.text("unexpected", { status: 500 }) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance(() => + yield* HttpServer.serveEffect()( Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("remote-start") - const info = workspaceInfo(Instance.project.id, type) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - yield* eventuallyEffect(Effect.gen(function* () { - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") - })) - yield* workspace.startWorkspaceSyncing(Instance.project.id) - yield* Effect.sleep("25 millis") - - expect( - captured.events - .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type) - .map((event) => event.payload.properties.status), - ).toEqual(["disconnected", "connecting", "connected"]) - expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) - expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1) - expect(yield* workspace.isSyncing(info.id)).toBe(true) - - yield* workspace.remove(info.id) - expect(yield* workspace.isSyncing(info.id)).toBe(false) - } finally { - captured.dispose() - } + 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 === "/sync/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (call.url.pathname === "/sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("remote-start") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe( + "connected", + ) + }), + ) + yield* workspace.startWorkspaceSyncing(Instance.project.id) + yield* Effect.sleep("25 millis") + + expect( + captured.events + .filter( + (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + ) + .map((event) => event.payload.properties.status), + ).toEqual(["disconnected", "connecting", "connected"]) + expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) + expect(calls.filter((call) => call.url.pathname === "/sync/sync/history")).toHaveLength(1) + expect(yield* workspace.isSyncing(info.id)).toBe(true) + + yield* workspace.remove(info.id) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + } finally { + captured.dispose() + } + }), { git: true }, ) }) @@ -766,31 +809,36 @@ describe("workspace-old sync state", () => { it.live("remote connection HTTP failures set error and clear syncing", () => Effect.gen(function* () { - yield* HttpServer.serveEffect()(Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - if (new URL(req.url, "http://localhost").pathname === "/failed/global/event") - return HttpServerResponse.text("nope", { status: 503 }) - return HttpServerResponse.fromWeb(Response.json([])) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance(() => + yield* HttpServer.serveEffect()( Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("remote-connect-fail") - const info = workspaceInfo(Instance.project.id, type) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - - yield* eventuallyEffect(Effect.gen(function* () { - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") - })) - expect(yield* workspace.isSyncing(info.id)).toBe(false) - yield* workspace.remove(info.id) + const req = yield* HttpServerRequest.HttpServerRequest + if (new URL(req.url, "http://localhost").pathname === "/failed/global/event") + return HttpServerResponse.text("nope", { status: 503 }) + return HttpServerResponse.fromWeb(Response.json([])) }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const type = unique("remote-connect-fail") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + }), + ) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), { git: true }, ) }), @@ -798,32 +846,39 @@ describe("workspace-old sync state", () => { it.live("remote history HTTP failures set error", () => Effect.gen(function* () { - yield* HttpServer.serveEffect()(Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const url = new URL(req.url, "http://localhost") - if (url.pathname === "/history-failed/global/event") return HttpServerResponse.fromWeb(eventStreamResponse([], false)) - if (url.pathname === "/history-failed/sync/history") return HttpServerResponse.text("history failed", { status: 500 }) - return HttpServerResponse.fromWeb(Response.json([])) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance(() => + yield* HttpServer.serveEffect()( Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - 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) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - - yield* eventuallyEffect(Effect.gen(function* () { - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") - })) - expect(yield* workspace.isSyncing(info.id)).toBe(false) - yield* workspace.remove(info.id) + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/history-failed/global/event") + return HttpServerResponse.fromWeb(eventStreamResponse([], false)) + if (url.pathname === "/history-failed/sync/history") + return HttpServerResponse.text("history failed", { status: 500 }) + return HttpServerResponse.fromWeb(Response.json([])) }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + 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) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + }), + ) + expect(yield* workspace.isSyncing(info.id)).toBe(false) + yield* workspace.remove(info.id) + }), { git: true }, ) }), @@ -834,60 +889,67 @@ describe("workspace-old sync state", () => { 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 url = new URL(req.url, "http://localhost") - if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) - if (url.pathname === "/history/sync/history") { - historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined) - return HttpServerResponse.fromWeb( - Response.json([ - { - id: `evt_${unique("history")}`, - aggregate_id: historySessionID!, - seq: historyNextSeq, - type: sessionUpdatedType(), - data: { sessionID: historySessionID!, info: { title: "from history" } }, - }, - ]), - ) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance(() => + yield* HttpServer.serveEffect()( Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("history-replay") - const info = workspaceInfo(Instance.project.id, type) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) - const session = yield* sessionSvc.create({ title: "before history" }) - attachSessionToWorkspace(session.id, info.id) - historySessionID = session.id - historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - - yield* eventuallyEffect(Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from history") - })) - expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) - expect( - captured.events.some( - (event) => - event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === historyNextSeq, - ), - ).toBe(true) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/history/global/event") return HttpServerResponse.fromWeb(eventStreamResponse()) + if (url.pathname === "/history/sync/history") { + historyBodies.push(bodyText ? JSON.parse(bodyText) : undefined) + return HttpServerResponse.fromWeb( + Response.json([ + { + id: `evt_${unique("history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from history" } }, + }, + ]), + ) + } + 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 captured = captureGlobalEvents() + try { + const type = unique("history-replay") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) + const session = yield* sessionSvc.create({ title: "before history" }) + attachSessionToWorkspace(session.id, info.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from history") + }), + ) + expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }]) + expect( + captured.events.some( + (event) => + event.workspace === info.id && + event.payload.type === "sync" && + event.payload.syncEvent.seq === historyNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), { git: true }, ) }) @@ -895,56 +957,70 @@ describe("workspace-old sync state", () => { it.live("SSE forwards non-heartbeat events and ignores heartbeats", () => Effect.gen(function* () { - yield* HttpServer.serveEffect()(Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const url = new URL(req.url, "http://localhost") - if (url.pathname === "/sse-forward/global/event") - return HttpServerResponse.fromWeb( - eventStreamResponse( - [ - { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } }, - { - directory: "remote-dir", - project: "remote-project", - payload: { type: "custom.remote", properties: { ok: true } }, - }, - ], - false, - ), - ) - if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([])) - return HttpServerResponse.text("unexpected", { status: 500 }) - })) - const url = yield* serverUrl() - yield* provideTmpdirInstance(() => + yield* HttpServer.serveEffect()( Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("sse-forward") - const info = workspaceInfo(Instance.project.id, type) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) - attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - - yield* eventuallyEffect(Effect.sync(() => - expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "custom.remote")) - .toBe(true), - )) - expect(captured.events.some((event) => event.workspace === info.id && event.payload.type === "server.heartbeat")).toBe( - false, - ) - expect( - captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"), - ).toMatchObject({ directory: "remote-dir", project: "remote-project", payload: { properties: { ok: true } } }) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-forward/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { directory: "remote-dir", project: "remote-project", payload: { type: "server.heartbeat" } }, + { + directory: "remote-dir", + project: "remote-project", + payload: { type: "custom.remote", properties: { ok: true } }, + }, + ], + false, + ), + ) + if (url.pathname === "/sse-forward/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("sse-forward") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) + attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.sync(() => + expect( + captured.events.some( + (event) => event.workspace === info.id && event.payload.type === "custom.remote", + ), + ).toBe(true), + ), + ) + expect( + captured.events.some( + (event) => event.workspace === info.id && event.payload.type === "server.heartbeat", + ), + ).toBe(false) + expect( + captured.events.find((event) => event.workspace === info.id && event.payload.type === "custom.remote"), + ).toMatchObject({ + directory: "remote-dir", + project: "remote-project", + payload: { properties: { ok: true } }, + }) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), { git: true }, ) }), @@ -954,65 +1030,73 @@ describe("workspace-old sync state", () => { let sseSessionID: SessionID | undefined let sseNextSeq = 0 return Effect.gen(function* () { - yield* HttpServer.serveEffect()(Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const url = new URL(req.url, "http://localhost") - if (url.pathname === "/sse-sync/global/event") - return HttpServerResponse.fromWeb( - eventStreamResponse( - [ - { - directory: "remote-dir", - project: "remote-project", - payload: { - type: "sync", - syncEvent: { - id: `evt_${unique("sse")}`, - aggregateID: sseSessionID!, - seq: sseNextSeq, - type: sessionUpdatedType(), - data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, "http://localhost") + if (url.pathname === "/sse-sync/global/event") + return HttpServerResponse.fromWeb( + eventStreamResponse( + [ + { + directory: "remote-dir", + project: "remote-project", + payload: { + type: "sync", + syncEvent: { + id: `evt_${unique("sse")}`, + aggregateID: sseSessionID!, + seq: sseNextSeq, + type: sessionUpdatedType(), + data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + }, }, }, - }, - ], - false, - ), - ) - if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) - 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 captured = captureGlobalEvents() - try { - const type = unique("sse-sync") - const info = workspaceInfo(Instance.project.id, type) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) - const session = yield* sessionSvc.create({ title: "before sse" }) - attachSessionToWorkspace(session.id, info.id) - sseSessionID = session.id - sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 - - yield* workspace.startWorkspaceSyncing(Instance.project.id) - - yield* eventuallyEffect(Effect.gen(function* () { - expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") - })) - expect( - captured.events.some( - (event) => event.workspace === info.id && event.payload.type === "sync" && event.payload.syncEvent.seq === sseNextSeq, - ), - ).toBe(true) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } + ], + false, + ), + ) + if (url.pathname === "/sse-sync/sync/history") return HttpServerResponse.fromWeb(Response.json([])) + 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 captured = captureGlobalEvents() + try { + const type = unique("sse-sync") + const info = workspaceInfo(Instance.project.id, type) + insertWorkspace(info) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) + const session = yield* sessionSvc.create({ title: "before sse" }) + attachSessionToWorkspace(session.id, info.id) + sseSessionID = session.id + sseNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.startWorkspaceSyncing(Instance.project.id) + + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* sessionSvc.get(session.id)).title).toBe("from sse") + }), + ) + expect( + captured.events.some( + (event) => + event.workspace === info.id && + event.payload.type === "sync" && + event.payload.syncEvent.seq === sseNextSeq, + ), + ).toBe(true) + yield* workspace.remove(info.id) + } finally { + captured.dispose() + } + }), { git: true }, ) }) @@ -1031,8 +1115,12 @@ describe("workspace-old waitForSync", () => { const sessionID = SessionID.descending("ses_wait_done") Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run()) - await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 })).resolves.toBeUndefined() - await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 })).resolves.toBeUndefined() + await expect( + WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }), + ).resolves.toBeUndefined() + await expect( + WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), + ).resolves.toBeUndefined() }) }) @@ -1084,23 +1172,23 @@ describe("workspace-old waitForSync", () => { ) abort.abort(reason) - await expect(waited).rejects.toMatchObject({ _tag: "WorkspaceSyncAbortedError", message: reason.message, cause: reason }) + await expect(waited).rejects.toMatchObject({ + _tag: "WorkspaceSyncAbortedError", + message: reason.message, + cause: reason, + }) }) }) - test( - "times out with the requested fence in the error message", - async () => { - await withInstance(async () => { - const sessionID = SessionID.descending("ses_wait_timeout") + test("times out with the requested fence in the error message", async () => { + await withInstance(async () => { + const sessionID = SessionID.descending("ses_wait_timeout") - await expect( - WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }), - ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`) - }) - }, - 7000, - ) + await expect( + WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }), + ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`) + }) + }, 7000) }) describe("workspace-old sessionRestore", () => { @@ -1132,75 +1220,84 @@ describe("workspace-old sessionRestore", () => { 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) => + yield* HttpServer.serveEffect()( 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) - registerAdaptor( - Instance.project.id, - type, - remoteAdaptor(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adaptor, - ) - 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() - } + 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) + registerAdaptor( + Instance.project.id, + type, + remoteAdaptor(`${url}/restore/?ignored=1#hash`, { + directory: dir, + headers: { authorization: "Bearer restore" }, + }).adaptor, + ) + 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 }, ) }) @@ -1209,35 +1306,40 @@ describe("workspace-old sessionRestore", () => { 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(() => + yield* HttpServer.serveEffect()( 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) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) - 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) + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) + 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 }, ) }) @@ -1246,48 +1348,55 @@ describe("workspace-old sessionRestore", () => { 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) => + yield* HttpServer.serveEffect()( 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) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) - 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() - } + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) + 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 }, ) }) @@ -1310,7 +1419,9 @@ describe("workspace-old sessionRestore", () => { const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) - const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({ title: "restore local" }))) + const session = await AppRuntime.runPromise( + SessionNs.Service.use((svc) => svc.create({ title: "restore local" })), + ) replaceSessionEvents(session.id, 20) expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) @@ -1318,7 +1429,9 @@ describe("workspace-old sessionRestore", () => { expect(fetchCallCount).toBe(0) expect(replayAll).toHaveBeenCalledTimes(3) expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1]) - expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(session.id)))).workspaceID).toBe(info.id) + expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.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 @@ -1335,55 +1448,60 @@ describe("workspace-old sessionRestore", () => { 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) => + yield* HttpServer.serveEffect()( 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) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) - 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() }, + 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, }) - 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) + 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) + registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) + 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/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d98d5c6fe18e..9bb1e50aac7f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -452,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: { @@ -506,38 +538,6 @@ export type EventPtyDeleted = { } } -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 OutputFormatText = { type: "text" } @@ -1141,16 +1141,16 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated @@ -2084,16 +2084,16 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index bfca971ef166..22e66c7d16c8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8743,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": { @@ -8901,102 +8997,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"] - }, "OutputFormatText": { "type": "object", "properties": { @@ -11048,34 +11048,34 @@ "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/Event.workspace.restore" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/Event.workspace.status" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/Event.worktree.ready" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/Event.worktree.failed" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.pty.created" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.pty.updated" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/Event.pty.exited" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/Event.pty.deleted" }, { "$ref": "#/components/schemas/Event.message.updated" @@ -13341,34 +13341,34 @@ "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/Event.workspace.restore" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/Event.workspace.status" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/Event.worktree.ready" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/Event.worktree.failed" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.pty.created" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.pty.updated" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/Event.pty.exited" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/Event.pty.deleted" }, { "$ref": "#/components/schemas/Event.message.updated" From 65c15afe9f78f2b0d2f400e94fab3194e81c77cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 11:48:13 -0400 Subject: [PATCH 0039/1114] test: use testEffect for instruction tests (#25046) --- packages/core/src/global.ts | 32 +- .../core/test/fixture/effect-flock-worker.ts | 25 +- packages/core/test/util/effect-flock.test.ts | 21 +- packages/opencode/src/session/instruction.ts | 316 +++++------ .../opencode/test/session/instruction.test.ts | 521 +++++++----------- 5 files changed, 383 insertions(+), 532 deletions(-) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 0c83e3a1fa93..42e0f1030a98 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -4,6 +4,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" +import { Flag } from "./flag/flag" const app = "opencode" const data = path.join(xdgData!, app) @@ -47,19 +48,28 @@ export interface Interface { readonly log: string } +export function make(input: Partial = {}): Interface { + return { + home: Path.home, + data: Path.data, + cache: Path.cache, + config: Flag.OPENCODE_CONFIG_DIR ?? Path.config, + state: Path.state, + bin: Path.bin, + log: Path.log, + ...input, + } +} + export const layer = Layer.effect( Service, - Effect.gen(function* () { - return Service.of({ - home: Path.home, - data: Path.data, - cache: Path.cache, - config: Path.config, - state: Path.state, - bin: Path.bin, - log: Path.log, - }) - }), + Effect.sync(() => Service.of(make())), ) +export const layerWith = (input: Partial) => + Layer.effect( + Service, + Effect.sync(() => Service.of(make(input))), + ) + export * as Global from "./global" diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3dc3ee2c8b6d..c442a62cf5cb 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -18,20 +18,17 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -const msg: Msg = JSON.parse(process.argv[2]!) - -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const msg: Msg = JSON.parse(process.argv[2]) + +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 9e8bc24ace2a..76cee4f8e024 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) { // Test layer // --------------------------------------------------------------------------- -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 5d91066b41f8..6629ce67bc9f 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -8,30 +7,15 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const log = Log.create({ service: "instruction" }) - const FILES = [ "AGENTS.md", ...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] -function globalFiles() { - const files = [] - if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) - } - files.push(path.join(Global.Path.config, "AGENTS.md")) - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - return files -} - function extract(messages: MessageV2.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -63,176 +47,180 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const fs = yield* AppFileSystem.Service - const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) - - const state = yield* InstanceState.make( - Effect.fn("Instruction.state")(() => - Effect.succeed({ - // Track which instruction files have already been attached for a given assistant message. - claims: new Map>(), - }), - ), - ) - - const relative = Effect.fnUntraced(function* (instruction: string) { - const ctx = yield* InstanceState.context - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - return yield* fs - .globUp(instruction, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - } - if (!Flag.OPENCODE_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, - ) - return [] - } +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient +> = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const globalFiles = [ + path.join(global.config, "AGENTS.md"), + ...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), + ] + + const state = yield* InstanceState.make( + Effect.fn("Instruction.state")(() => + Effect.succeed({ + // Track which instruction files have already been attached for a given assistant message. + claims: new Map>(), + }), + ), + ) + + const relative = Effect.fnUntraced(function* (instruction: string) { + const ctx = yield* InstanceState.context + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { return yield* fs - .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR) + .globUp(instruction, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - }) - - const read = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - }) - - const fetch = Effect.fnUntraced(function* (url: string) { - const res = yield* http.execute(HttpClientRequest.get(url)).pipe( - Effect.timeout(5000), - Effect.catch(() => Effect.succeed(null)), - ) - if (!res) return "" - const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) - return new TextDecoder().decode(body) - }) - - const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { - const s = yield* InstanceState.get(state) - s.claims.delete(messageID) - }) - - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const ctx = yield* InstanceState.context - const paths = new Set() - - for (const file of globalFiles()) { - if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) - break - } + } + return yield* fs + .globUp(instruction, global.config, global.config) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + }) + + const read = Effect.fnUntraced(function* (filepath: string) { + return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + }) + + const fetch = Effect.fnUntraced(function* (url: string) { + const res = yield* http.execute(HttpClientRequest.get(url)).pipe( + Effect.timeout(5000), + Effect.catch(() => Effect.succeed(null)), + ) + if (!res) return "" + const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) + return new TextDecoder().decode(body) + }) + + const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { + const s = yield* InstanceState.get(state) + s.claims.delete(messageID) + }) + + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const config = yield* cfg.get() + const ctx = yield* InstanceState.context + const paths = new Set() + + for (const file of globalFiles) { + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + break } + } - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of FILES) { - const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break - } + // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of FILES) { + const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) + break } } + } - if (config.instructions) { - for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue - const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw - const matches = yield* ( - path.isAbsolute(instruction) - ? fs.glob(path.basename(instruction), { - cwd: path.dirname(instruction), - absolute: true, - include: "file", - }) - : relative(instruction) - ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) - } + if (config.instructions) { + for (const raw of config.instructions) { + if (raw.startsWith("https://") || raw.startsWith("http://")) continue + const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw + const matches = yield* ( + path.isAbsolute(instruction) + ? fs.glob(path.basename(instruction), { + cwd: path.dirname(instruction), + absolute: true, + include: "file", + }) + : relative(instruction) + ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + matches.forEach((item) => paths.add(path.resolve(item))) } + } - return paths - }) + return paths + }) - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - const urls = (config.instructions ?? []).filter( - (item) => item.startsWith("https://") || item.startsWith("http://"), - ) + const system = Effect.fn("Instruction.system")(function* () { + const config = yield* cfg.get() + const paths = yield* systemPaths() + const urls = (config.instructions ?? []).filter( + (item) => item.startsWith("https://") || item.startsWith("http://"), + ) - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) + const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) + const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) - return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), - ] - }) + return [ + ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), + ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ] + }) - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath + const find = Effect.fn("Instruction.find")(function* (dir: string) { + for (const file of FILES) { + const filepath = path.resolve(path.join(dir, file)) + if (yield* fs.existsSafe(filepath)) return filepath + } + return undefined + }) + + const resolve = Effect.fn("Instruction.resolve")(function* ( + messages: MessageV2.WithParts[], + filepath: string, + messageID: MessageID, + ) { + const sys = yield* systemPaths() + const already = extract(messages) + const results: { filepath: string; content: string }[] = [] + const s = yield* InstanceState.get(state) + const root = path.resolve(yield* InstanceState.directory) + + const target = path.resolve(filepath) + let current = path.dirname(target) + + // Walk upward from the file being read and attach nearby instruction files once per message. + while (current.startsWith(root) && current !== root) { + const found = yield* find(current) + if (!found || found === target || sys.has(found) || already.has(found)) { + current = path.dirname(current) + continue } - }) - - const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: MessageV2.WithParts[], - filepath: string, - messageID: MessageID, - ) { - const sys = yield* systemPaths() - const already = extract(messages) - const results: { filepath: string; content: string }[] = [] - const s = yield* InstanceState.get(state) - const root = path.resolve(yield* InstanceState.directory) - - const target = path.resolve(filepath) - let current = path.dirname(target) - - // Walk upward from the file being read and attach nearby instruction files once per message. - while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } - - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } - - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } + let set = s.claims.get(messageID) + if (!set) { + set = new Set() + s.claims.set(messageID, set) + } + if (set.has(found)) { current = path.dirname(current) + continue } - return results - }) + set.add(found) + const content = yield* read(found) + if (content) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + } - return Service.of({ clear, systemPaths, system, find, resolve }) - }), - ) + current = path.dirname(current) + } + + return results + }) + + return Service.of({ clear, systemPaths, system, find, resolve }) + }), +) export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Global.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index a9926b1e22e8..f80081759426 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,16 +1,76 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" import path from "path" -import { Effect } from "effect" +import { Effect, FileSystem, Layer } from "effect" +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" -import { Instance } from "../../src/project/instance" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +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 instructionLayer = (global: Partial) => + Instruction.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Global.layerWith(global)), + ) + +const provideInstruction = + (global: Partial) => + (self: Effect.Effect) => + self.pipe(Effect.provide(instructionLayer(global))) + +const write = (filepath: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(filepath), { recursive: true }) + yield* fs.writeFileString(filepath, content) + }) -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer))) +const writeFiles = (dir: string, files: Record) => + Effect.all( + Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)), + { discard: true }, + ) + +const withFiles = (files: Record, self: (dir: string) => Effect.Effect) => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* writeFiles(dir, files) + return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir })) + }), + ) + +const tmpWithFiles = (files: Record) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeFiles(dir, files) + return dir + }) function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") @@ -52,336 +112,135 @@ function loaded(filepath: string): MessageV2.WithParts[] { } describe("Instruction.resolve", () => { - test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions") - await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) - - const results = yield* svc.resolve( - [], - path.join(tmp.path, "src", "file.ts"), - MessageID.make("message-test-1"), - ) - expect(results).toEqual([]) - }), - ), - ), - }) - }) - - test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) - - const results = yield* svc.resolve( - [], - path.join(tmp.path, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), - ) - expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) - - test("doesn't reload AGENTS.md when reading it directly", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "AGENTS.md") - const system = yield* svc.systemPaths() - expect(system.has(filepath)).toBe(false) - - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) - expect(results).toEqual([]) - }), - ), - ), - }) - }) - - test("does not reattach the same nearby instructions twice for one message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") - - const first = yield* svc.resolve([], filepath, id) - const second = yield* svc.resolve([], filepath, id) - - expect(first).toHaveLength(1) - expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - expect(second).toEqual([]) - }), - ), - ), - }) - }) - - test("clear allows nearby instructions to be attached again for the same message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") - - const first = yield* svc.resolve([], filepath, id) - yield* svc.clear(id) - const second = yield* svc.resolve([], filepath, id) - - expect(first).toHaveLength(1) - expect(second).toHaveLength(1) - expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) - - test("skips instructions already reported by prior read metadata", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const agents = path.join(tmp.path, "subdir", "AGENTS.md") - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") - - const results = yield* svc.resolve(loaded(agents), filepath, id) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () => + withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) + + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + expect(results).toEqual([]) + }), + ), + ) + + it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false) + + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("message-test-2"), + ) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) + + it.live("doesn't reload AGENTS.md when reading it directly", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "AGENTS.md") + const system = yield* svc.systemPaths() + expect(system.has(filepath)).toBe(false) + + const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + expect(results).toEqual([]) + }), + ), + ) + + it.live("does not reattach the same nearby instructions twice for one message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-1") + + const first = yield* svc.resolve([], filepath, id) + const second = yield* svc.resolve([], filepath, id) + + expect(first).toHaveLength(1) + expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + expect(second).toEqual([]) + }), + ), + ) + + it.live("clear allows nearby instructions to be attached again for the same message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-2") + + const first = yield* svc.resolve([], filepath, id) + yield* svc.clear(id) + const second = yield* svc.resolve([], filepath, id) + + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) + + it.live("skips instructions already reported by prior read metadata", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const agents = path.join(dir, "subdir", "AGENTS.md") + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-3") + + const results = yield* svc.resolve(loaded(agents), filepath, id) + expect(results).toEqual([]) + }), + ), + ) test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) }) describe("Instruction.system", () => { - test("loads both project and global AGENTS.md when both exist", async () => { - const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] - delete process.env["OPENCODE_CONFIG_DIR"] - - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") - }, - }) - - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - - const rules = yield* svc.system() - expect(rules).toHaveLength(2) - expect(rules[0]).toBe( - `Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`, - ) - expect(rules[1]).toBe( - `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, - ) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - if (originalConfigDir === undefined) { - delete process.env["OPENCODE_CONFIG_DIR"] - } else { - process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir - } - } - }) + it.live("loads both project and global AGENTS.md when both exist", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" }) + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + + const rules = yield* svc.system() + expect(rules).toHaveLength(2) + expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) + expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) }) -describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { - let originalConfigDir: string | undefined - - beforeEach(() => { - originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] - }) - - afterEach(() => { - if (originalConfigDir === undefined) { - delete process.env["OPENCODE_CONFIG_DIR"] - } else { - process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir - } - }) - - test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => { - await using profileTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions") - }, - }) - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) - - test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => { - await using profileTmp = await tmpdir() - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) - - test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - delete process.env["OPENCODE_CONFIG_DIR"] - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) +describe("Instruction.systemPaths global config", () => { + it.live("uses Global.Service config AGENTS.md", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) }) From 375444a149780c7121bd8964685c4bfe8edd1870 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 15:48:28 +0000 Subject: [PATCH 0040/1114] 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 9ec814b8e265..3691154c4705 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=", - "aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=", - "aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=", - "x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk=" + "x86_64-linux": "sha256-cBfg4pJ4mjsfS4MFFASBaZZykArgIoeo/3woOcSGy1U=", + "aarch64-linux": "sha256-Q6cqUwfqbscdrPW0uHcfshhQINjJi0HiyURMSdOOCf4=", + "aarch64-darwin": "sha256-1AtfsD1D9YxWSEsecPJF9XsvsxsWTtVtkP5l6UW43og=", + "x86_64-darwin": "sha256-YS5/8YTf9LymAUbjXVrGDfxtKVJrpZbPnnCtsGHSHoU=" } } From ffe0314c47302cbb4aac505e5fe2b36472f6f68e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:15:53 -0500 Subject: [PATCH 0041/1114] fix: ensure disabling OPENCODE_DISABLE_CLAUDE_CODE_SKILLS doesnt disable external skills too (#25123) --- packages/core/src/flag/flag.ts | 2 +- packages/opencode/src/skill/index.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 72c8931f5b71..a3b8133b6466 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -47,7 +47,7 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE, OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, - OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 701ecaba8957..9750742f97f6 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -20,7 +20,8 @@ import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" const log = Log.create({ service: "skill" }) -const EXTERNAL_DIRS = [".claude", ".agents"] +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" @@ -152,15 +153,19 @@ const discoverSkills = Effect.fnUntraced(function* ( ) { const state: ScanState = { matches: new Set(), dirs: new Set() } + const externalDirs: string[] = [] if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { const root = path.join(Global.Path.home, dir) if (!(yield* fsys.isDir(root))) continue yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } const upDirs = yield* fsys - .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree }) + .up({ targets: externalDirs, start: directory, stop: worktree }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) for (const root of upDirs) { From fef79819425714c1cbd969dd2139b8c4b96e0d16 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:45:30 -0400 Subject: [PATCH 0042/1114] test: use Effect runtime in runner deadlock case (#25045) --- packages/opencode/test/effect/runner.test.ts | 94 +++++++++----------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 80870a234e22..97ca9f6161b7 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Runner } from "@/effect/runner" import { it } from "../lib/effect" @@ -198,58 +198,52 @@ describe("Runner", () => { }), ) - test("cancel does not deadlock when replacement work starts before interrupted run exits", async () => { - function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } - } + it.live( + "cancel does not deadlock when replacement work starts before interrupted run exits", + Effect.gen(function* () { + const s = yield* Scope.Scope + const hit = yield* Deferred.make() + const hold = yield* Deferred.make() + const done = yield* Deferred.make() + + yield* Effect.gen(function* () { + const runner = Runner.make(s) + const first = Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(hit, undefined)), + Effect.ensuring(Deferred.await(hold)), + Effect.as("first"), + ) - function fail(ms: number, msg: string) { - return new Promise((_, reject) => { - setTimeout(() => reject(new Error(msg)), ms) - }) - } + const a = yield* runner.ensureRunning(first).pipe(Effect.exit, Effect.forkChild) + yield* Effect.sleep("10 millis") - const s = await Effect.runPromise(Scope.make()) - const hit = defer() - const hold = defer() - const done = defer() - try { - const runner = Runner.make(s) - const first = Effect.never.pipe( - Effect.onInterrupt(() => Effect.sync(() => hit.resolve())), - Effect.ensuring(Effect.promise(() => hold.promise)), - Effect.as("first"), + const stop = yield* runner.cancel.pipe(Effect.forkChild) + yield* Deferred.await(hit).pipe(Effect.timeout("250 millis")) + + const b = yield* runner.ensureRunning(Deferred.await(done).pipe(Effect.as("second"))).pipe(Effect.forkChild) + yield* Effect.yieldNow + expect(runner.busy).toBe(true) + + yield* Deferred.succeed(hold, undefined) + const stopExit = yield* Fiber.await(stop).pipe(Effect.timeout("250 millis")) + expect(Exit.isSuccess(stopExit)).toBe(true) + + expect(runner.busy).toBe(true) + yield* Deferred.succeed(done, undefined) + expect(yield* Fiber.join(b).pipe(Effect.timeout("250 millis"))).toBe("second") + expect(runner.busy).toBe(false) + + const exit = yield* Fiber.join(a) + expect(Exit.isFailure(exit)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.all([Deferred.succeed(hold, undefined), Deferred.succeed(done, undefined)], { discard: true }).pipe( + Effect.ignore, + ), + ), ) - - const a = Effect.runPromiseExit(runner.ensureRunning(first)) - await Bun.sleep(10) - - const stop = Effect.runPromise(runner.cancel) - await Promise.race([hit.promise, fail(250, "cancel did not interrupt running work")]) - - const b = Effect.runPromise(runner.ensureRunning(Effect.promise(() => done.promise).pipe(Effect.as("second")))) - expect(runner.busy).toBe(true) - - hold.resolve() - await Promise.race([stop, fail(250, "cancel deadlocked while replacement run was active")]) - - expect(runner.busy).toBe(true) - done.resolve() - expect(await b).toBe("second") - expect(runner.busy).toBe(false) - - const exit = await a - expect(Exit.isFailure(exit)).toBe(true) - } finally { - hold.resolve() - done.resolve() - await Promise.race([Effect.runPromise(Scope.close(s, Exit.void)), fail(1000, "runner scope did not close")]) - } - }) + }), + ) // --- shell semantics --- From ce63ca4d7a3552a5e35f98e698967c0d499b2c1d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:51:32 -0400 Subject: [PATCH 0043/1114] test: use testEffect for system prompt test (#25047) --- packages/opencode/src/skill/index.ts | 12 +- packages/opencode/test/cli/tui/thread.test.ts | 55 +++++--- packages/opencode/test/session/system.test.ts | 124 +++++++++--------- 3 files changed, 109 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 9750742f97f6..a4e3fb6d9311 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -148,6 +147,7 @@ const discoverSkills = Effect.fnUntraced(function* ( config: Config.Interface, discovery: Discovery.Interface, fsys: AppFileSystem.Interface, + global: Global.Interface, directory: string, worktree: string, ) { @@ -159,7 +159,7 @@ const discoverSkills = Effect.fnUntraced(function* ( externalDirs.push(AGENTS_EXTERNAL_DIR) for (const dir of externalDirs) { - const root = path.join(Global.Path.home, dir) + const root = path.join(global.home, dir) if (!(yield* fsys.isDir(root))) continue yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } @@ -180,7 +180,7 @@ const discoverSkills = Effect.fnUntraced(function* ( const cfg = yield* config.get() for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) if (!(yield* fsys.isDir(dir))) { log.warn("skill path not found", { path: dir }) @@ -221,13 +221,14 @@ export const layer = Layer.effect( const config = yield* Config.Service const bus = yield* Bus.Service const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service const discovered = yield* InstanceState.make( Effect.fn("Skill.discovery")(function* (ctx) { - return yield* discoverSkills(config, discovery, fsys, ctx.directory, ctx.worktree) + return yield* discoverSkills(config, discovery, fsys, global, ctx.directory, ctx.worktree) }), ) const state = yield* InstanceState.make( - Effect.fn("Skill.state")(function* (ctx) { + Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s @@ -264,6 +265,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layer), ) export function fmt(list: Info[], opts: { verbose: boolean }) { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index e2bd9d7bcccf..b7435565564d 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -3,18 +3,44 @@ import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" import * as App from "../../../src/cli/cmd/tui/app" -import { Rpc } from "@/util/rpc" import { UI } from "../../../src/cli/ui" import * as Timeout from "../../../src/util/timeout" import * as Network from "../../../src/cli/network" import * as Win32 from "../../../src/cli/cmd/tui/win32" -import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" const stop = new Error("stop") +const packageRoot = path.resolve(import.meta.dir, "../../..") const seen = { tui: [] as string[], } +class TestWorker extends EventTarget { + onerror: Worker["onerror"] = null + onmessage: Worker["onmessage"] = null + onmessageerror: Worker["onmessageerror"] = null + + postMessage(data: string) { + const parsed = JSON.parse(data) + if (!parsed || typeof parsed !== "object" || !("method" in parsed) || !("id" in parsed)) return + if (typeof parsed.method !== "string" || typeof parsed.id !== "number") return + const result = + parsed.method === "fetch" + ? { status: 200, headers: {}, body: "" } + : parsed.method === "server" + ? { url: "http://127.0.0.1" } + : parsed.method === "snapshot" + ? "" + : undefined + queueMicrotask(() => { + this.onmessage?.( + new MessageEvent("message", { data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }) }), + ) + }) + } + + terminate() {} +} + function setup() { // Intentionally avoid mock.module() here: Bun keeps module overrides in cache // and mock.restore() does not reset mock.module values. If this switches back @@ -25,10 +51,6 @@ function setup() { if (input.directory) seen.tui.push(input.directory) throw stop }) - spyOn(Rpc, "client").mockImplementation(() => ({ - call: async () => ({ url: "http://127.0.0.1" }) as never, - on: () => () => {}, - })) spyOn(UI, "error").mockImplementation(() => {}) spyOn(Timeout, "withTimeout").mockImplementation((input) => input) spyOn(Network, "resolveNetworkOptions").mockResolvedValue({ @@ -71,7 +93,6 @@ describe("tui thread", () => { async function check(project?: string) { setup() - const cwd = process.cwd() const pwd = process.env.PWD const worker = globalThis.Worker const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") @@ -85,26 +106,26 @@ describe("tui thread", () => { configurable: true, value: true, }) - globalThis.Worker = class extends EventTarget { - onerror = null - onmessage = null - onmessageerror = null - postMessage() {} - terminate() {} - } as unknown as typeof Worker + Object.defineProperty(globalThis, "Worker", { configurable: true, value: TestWorker }) try { process.chdir(tmp.path) process.env.PWD = link - await expect(call(project)).rejects.toBe(stop) + let error: unknown + try { + await call(project) + } catch (caught) { + error = caught + } + expect(error).toBe(stop) expect(seen.tui[0]).toBe(tmp.path) } finally { - process.chdir(cwd) + process.chdir(packageRoot) if (pwd === undefined) delete process.env.PWD else process.env.PWD = pwd if (tty) Object.defineProperty(process.stdin, "isTTY", tty) else delete (process.stdin as { isTTY?: boolean }).isTTY - globalThis.Worker = worker + Object.defineProperty(globalThis, "Worker", { configurable: true, value: worker }) await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) } } diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 33123acce64c..6e5439da5805 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,69 +1,73 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { Effect } from "effect" -import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import type { Agent } from "../../src/agent/agent" +import { NamedError } from "@opencode-ai/core/util/error" +import { Skill } from "../../src/skill" +import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { - return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) -} - -describe("session.system", () => { - test("skills output is sorted by name and stable across calls", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - for (const [name, description] of [ - ["zeta-skill", "Zeta skill."], - ["alpha-skill", "Alpha skill."], - ["middle-skill", "Middle skill."], - ]) { - const skillDir = path.join(dir, ".opencode", "skill", name) - await Bun.write( - path.join(skillDir, "SKILL.md"), - `--- -name: ${name} -description: ${description} ---- +const skills: Skill.Info[] = [ + { + name: "zeta-skill", + description: "Zeta skill.", + location: "/tmp/zeta-skill/SKILL.md", + content: "# zeta-skill", + }, + { + name: "alpha-skill", + description: "Alpha skill.", + location: "/tmp/alpha-skill/SKILL.md", + content: "# alpha-skill", + }, + { + name: "middle-skill", + description: "Middle skill.", + location: "/tmp/middle-skill/SKILL.md", + content: "# middle-skill", + }, +] -# ${name} -`, - ) - } - }, - }) - - const home = process.env.OPENCODE_TEST_HOME - process.env.OPENCODE_TEST_HOME = tmp.path +const build: Agent.Info = { + name: "build", + mode: "primary", + permission: Permission.fromConfig({ "*": "allow" }), + options: {}, +} - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const build = await load(tmp.path, (svc) => svc.get("build")) - const runSkills = Effect.gen(function* () { - const svc = yield* SystemPrompt.Service - return yield* svc.skills(build!) - }).pipe(Effect.provide(SystemPrompt.defaultLayer)) +const it = testEffect( + SystemPrompt.layer.pipe( + Layer.provide( + Layer.succeed( + Skill.Service, + Skill.Service.of({ + get: (name) => Effect.succeed(skills.find((skill) => skill.name === name)), + all: () => Effect.succeed(skills), + dirs: () => Effect.succeed([]), + available: () => Effect.succeed(skills), + }), + ), + ), + ), +) - const first = await Effect.runPromise(runSkills) - const second = await Effect.runPromise(runSkills) +describe("session.system", () => { + it.effect("skills output is sorted by name and stable across calls", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const first = yield* prompt.skills(build) + const second = yield* prompt.skills(build) + const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" }))) - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first!.indexOf("alpha-skill") - const middle = first!.indexOf("middle-skill") - const zeta = first!.indexOf("zeta-skill") + const alpha = output.indexOf("alpha-skill") + const middle = output.indexOf("middle-skill") + const zeta = output.indexOf("zeta-skill") - expect(alpha).toBeGreaterThan(-1) - expect(middle).toBeGreaterThan(alpha) - expect(zeta).toBeGreaterThan(middle) - }, - }) - } finally { - process.env.OPENCODE_TEST_HOME = home - } - }) + expect(alpha).toBeGreaterThan(-1) + expect(middle).toBeGreaterThan(alpha) + expect(zeta).toBeGreaterThan(middle) + }), + ) }) From 92e80b466036f4e1ceedd93296997eeebbdd0592 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:52:29 -0400 Subject: [PATCH 0044/1114] test: use Effect test helper for app runtime logger (#25049) --- .../test/effect/app-runtime-logger.test.ts | 96 ++++++++++--------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index dc88c60bf8bd..fe9516ef99f8 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,12 +1,15 @@ -import { expect, test } from "bun:test" +import { expect } from "bun:test" import { Context, Effect, Layer, Logger } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppRuntime } from "../../src/effect/app-runtime" import { EffectBridge } from "@/effect/bridge" import { InstanceRef } from "../../src/effect/instance-ref" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { makeRuntime } from "../../src/effect/run-service" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) function check(loggers: ReadonlySet>) { return { @@ -17,56 +20,58 @@ function check(loggers: ReadonlySet>) { } } -test("makeRuntime installs EffectLogger through Observability.layer", async () => { - class Dummy extends Context.Service Effect.Effect> }>()( - "@test/Dummy", - ) {} - - const layer = Layer.effect( - Dummy, - Effect.gen(function* () { - return Dummy.of({ - current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check), - }) - }), - ) +it.live("makeRuntime installs EffectLogger through Observability.layer", () => + Effect.gen(function* () { + class Dummy extends Context.Service Effect.Effect> }>()( + "@test/Dummy", + ) {} - const rt = makeRuntime(Dummy, layer) - const current = await rt.runPromise((svc) => svc.current()) + const layer = Layer.effect( + Dummy, + Effect.gen(function* () { + return Dummy.of({ + current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check), + }) + }), + ) - expect(current.effectLogger).toBe(true) - expect(current.defaultLogger).toBe(false) -}) + const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current())) -test("AppRuntime also installs EffectLogger through Observability.layer", async () => { - const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)) + expect(current.effectLogger).toBe(true) + expect(current.defaultLogger).toBe(false) + }), +) - expect(current.effectLogger).toBe(true) - expect(current.defaultLogger).toBe(false) -}) +it.live("AppRuntime also installs EffectLogger through Observability.layer", () => + Effect.gen(function* () { + const current = yield* Effect.promise(() => + AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)), + ) -test("AppRuntime attaches InstanceRef from ALS", async () => { - await using tmp = await tmpdir({ git: true }) + expect(current.effectLogger).toBe(true) + expect(current.defaultLogger).toBe(false) + }), +) - const dir = await Instance.provide({ - directory: tmp.path, - fn: () => +it.live("AppRuntime attaches InstanceRef from ALS", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const current = yield* Effect.promise(() => AppRuntime.runPromise( Effect.gen(function* () { return (yield* InstanceRef)?.directory }), ), - }) - - expect(dir).toBe(tmp.path) -}) + ).pipe(provideInstance(dir)) -test("EffectBridge preserves logger and instance context across async boundaries", async () => { - await using tmp = await tmpdir({ git: true }) + expect(current).toBe(dir) + }), +) - const result = await Instance.provide({ - directory: tmp.path, - fn: () => +it.live("EffectBridge preserves logger and instance context across async boundaries", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const result = yield* Effect.promise(() => AppRuntime.runPromise( Effect.gen(function* () { const bridge = yield* EffectBridge.make() @@ -84,9 +89,10 @@ test("EffectBridge preserves logger and instance context across async boundaries ) }), ), - }) + ).pipe(provideInstance(dir)) - expect(result.directory).toBe(tmp.path) - expect(result.effectLogger).toBe(true) - expect(result.defaultLogger).toBe(false) -}) + expect(result.directory).toBe(dir) + expect(result.effectLogger).toBe(true) + expect(result.defaultLogger).toBe(false) + }), +) From 79e23b7eb9c15b09689bb66781b242c4bb8b47d9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:53:13 -0400 Subject: [PATCH 0045/1114] test: use testEffect for instance state (#25115) --- .../test/effect/instance-state.test.ts | 783 ++++++++---------- 1 file changed, 347 insertions(+), 436 deletions(-) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 710c244748aa..54b2b42c8ff8 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,482 +1,393 @@ -import { afterEach, expect, test } from "bun:test" -import { Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, Context } from "effect" +import { afterEach, expect } from "bun:test" +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 { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -async function access(state: InstanceState.InstanceState, dir: string) { - return Instance.provide({ - directory: dir, - fn: () => Effect.runPromise(InstanceState.get(state)), - }) -} +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +const access = (state: InstanceState.InstanceState, dir: string) => + InstanceState.get(state).pipe(provideInstance(dir)) + +const tmpdirGitScoped = Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git commit --allow-empty --amend -m ${`root commit ${dir}`}`.cwd(dir).quiet()) + return dir +}) afterEach(async () => { await Instance.disposeAll() }) -test("InstanceState caches values per directory", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - const b = yield* Effect.promise(() => access(state, tmp.path)) +it.live("InstanceState caches values per directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) + + const a = yield* access(state, dir) + const b = yield* access(state, dir) + + expect(a).toBe(b) + expect(n).toBe(1) + }), +) + +it.live("InstanceState isolates directories", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + + const a = yield* access(state, one) + const b = yield* access(state, two) + const c = yield* access(state, one) + + expect(a).toBe(c) + expect(a).not.toBe(b) + expect(n).toBe(2) + }), +) + +it.live("InstanceState invalidates on reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const seen: string[] = [] + let n = 0 + const state = yield* InstanceState.make(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), + ), + ) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) + const a = yield* access(state, dir) + yield* Effect.promise(() => Instance.reload({ directory: dir })) + const b = yield* access(state, dir) + + expect(a).not.toBe(b) + expect(seen).toEqual(["1"]) + }), +) + +it.live("InstanceState invalidates on disposeAll", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const seen: string[] = [] + const state = yield* InstanceState.make((ctx) => + Effect.acquireRelease( + Effect.sync(() => ({ dir: ctx.directory })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) -test("InstanceState isolates directories", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - let n = 0 + yield* access(state, one) + yield* access(state, two) + yield* Effect.promise(() => Instance.disposeAll()) - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) + expect(seen.sort()).toEqual([one, two].sort()) + }), +) - const a = yield* Effect.promise(() => access(state, one.path)) - const b = yield* Effect.promise(() => access(state, two.path)) - const c = yield* Effect.promise(() => access(state, one.path)) +it.live("InstanceState.get reads the current directory lazily", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() - expect(a).toBe(c) - expect(a).not.toBe(b) - expect(n).toBe(2) - }), - ), - ) -}) + interface Api { + readonly get: () => Effect.Effect + } -test("InstanceState invalidates on reload", async () => { - await using tmp = await tmpdir() - const seen: string[] = [] - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.acquireRelease( - Effect.sync(() => ({ n: ++n })), - (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - ), - ) - - const a = yield* Effect.promise(() => access(state, tmp.path)) - yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) - const b = yield* Effect.promise(() => access(state, tmp.path)) - - expect(a).not.toBe(b) - expect(seen).toEqual(["1"]) - }), - ), - ) -}) + class Test extends Context.Service()("@test/InstanceStateLazy") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + const get = InstanceState.get(state) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* get + }), + }) + }), + ) + } -test("InstanceState invalidates on disposeAll", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - const seen: string[] = [] - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.acquireRelease( - Effect.sync(() => ({ dir: ctx.directory })), - (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - ), - ) - - yield* Effect.promise(() => access(state, one.path)) - yield* Effect.promise(() => access(state, two.path)) - yield* Effect.promise(() => Instance.disposeAll()) - - expect(seen.sort()).toEqual([one.path, two.path].sort()) - }), - ), - ) -}) + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + const b = yield* Test.use((svc) => svc.get()).pipe(provideInstance(two)) -test("InstanceState.get reads the current directory lazily", async () => { - await using one = await tmpdir() - await using two = await tmpdir() + expect(a).toBe(one) + expect(b).toBe(two) + }).pipe(Effect.provide(Test.layer)) + }), +) - interface Api { - readonly get: () => Effect.Effect - } +it.live("InstanceState preserves directory across async boundaries", () => + Effect.gen(function* () { + const one = yield* tmpdirGitScoped + const two = yield* tmpdirGitScoped + const three = yield* tmpdirGitScoped - class Test extends Context.Service()("@test/InstanceStateLazy") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - const get = InstanceState.get(state) + interface Api { + readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> + } - return Test.of({ - get: Effect.fn("Test.get")(function* () { - return yield* get - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const a = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - const b = await Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - - expect(a).toBe(one.path) - expect(b).toBe(two.path) - } finally { - await rt.dispose() - } -}) + class Test extends Context.Service()("@test/InstanceStateAsync") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.sync(() => ({ + directory: ctx.directory, + worktree: ctx.worktree, + project: ctx.project.id, + })), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + yield* Effect.promise(() => Bun.sleep(1)) + yield* Effect.sleep(Duration.millis(1)) + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + yield* Effect.sleep(Duration.millis(2)) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } -test("InstanceState preserves directory across async boundaries", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - await using three = await tmpdir({ git: true }) - - interface Api { - readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }> - } - - class Test extends Context.Service()("@test/InstanceStateAsync") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.sync(() => ({ - directory: ctx.directory, - worktree: ctx.worktree, - project: ctx.project.id, - })), - ) - - return Test.of({ - get: Effect.fn("Test.get")(function* () { - yield* Effect.promise(() => Bun.sleep(1)) - yield* Effect.sleep(Duration.millis(1)) - for (let i = 0; i < 100; i++) { - yield* Effect.yieldNow - } - for (let i = 0; i < 100; i++) { - yield* Effect.promise(() => Promise.resolve()) - } - yield* Effect.sleep(Duration.millis(2)) - yield* Effect.promise(() => Bun.sleep(1)) - return yield* InstanceState.get(state) - }), - }) - }), + yield* Effect.gen(function* () { + const [a, b, c] = yield* Effect.all( + [one, two, three].map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + { concurrency: "unbounded" }, + ) + + expect(a).toEqual({ directory: one, worktree: one, project: a.project }) + expect(b).toEqual({ directory: two, worktree: two, project: b.project }) + expect(c).toEqual({ directory: three, worktree: three, project: c.project }) + expect(a.project).not.toBe(b.project) + expect(a.project).not.toBe(c.project) + expect(b.project).not.toBe(c.project) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState survives high-contention concurrent access", () => + Effect.gen(function* () { + const dirs = yield* Effect.all( + Array.from({ length: 20 }, () => tmpdirScoped()), + { concurrency: "unbounded" }, ) - } - const rt = ManagedRuntime.make(Test.layer) + interface Api { + readonly get: () => Effect.Effect + } - try { - const [a, b, c] = await Promise.all([ - Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - Instance.provide({ - directory: three.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - ]) - - expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project }) - expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project }) - expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project }) - expect(a.project).not.toBe(b.project) - expect(a.project).not.toBe(c.project) - expect(b.project).not.toBe(c.project) - } finally { - await rt.dispose() - } -}) + class Test extends Context.Service()("@test/HighContention") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + for (let i = 0; i < 10; i++) { + yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) + yield* Effect.yieldNow + yield* Effect.promise(() => Promise.resolve()) + } + return yield* InstanceState.get(state) + }), + }) + }), + ) + } -test("InstanceState survives high-contention concurrent access", async () => { - const N = 20 - const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir())) - - interface Api { - readonly get: () => Effect.Effect - } - - class Test extends Context.Service()("@test/HighContention") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) - - return Test.of({ - get: Effect.fn("Test.get")(function* () { - // Interleave many async hops to maximize chance of ALS corruption - for (let i = 0; i < 10; i++) { - yield* Effect.promise(() => Bun.sleep(Math.random() * 3)) - yield* Effect.yieldNow - yield* Effect.promise(() => Promise.resolve()) - } - return yield* InstanceState.get(state) - }), - }) - }), - ) - } + yield* Effect.gen(function* () { + const results = yield* Effect.all( + dirs.map((dir) => Test.use((svc) => svc.get()).pipe(provideInstance(dir))), + { concurrency: "unbounded" }, + ) - const rt = ManagedRuntime.make(Test.layer) + expect(results).toEqual(dirs) + }).pipe(Effect.provide(Test.layer)) + }), +) - try { - const results = await Promise.all( - dirs.map((d) => - Instance.provide({ - directory: d.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - ), - ) +it.live("InstanceState correct after interleaved init and dispose", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() - for (let i = 0; i < N; i++) { - expect(results[i]).toBe(dirs[i].path) + interface Api { + readonly get: () => Effect.Effect } - } finally { - await rt.dispose() - for (const d of dirs) await d[Symbol.asyncDispose]() - } -}) -test("InstanceState correct after interleaved init and dispose", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - - interface Api { - readonly get: () => Effect.Effect - } - - class Test extends Context.Service()("@test/InterleavedDispose") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => - Effect.promise(async () => { - await Bun.sleep(5) // slow init - return ctx.directory - }), - ) + class Test extends Context.Service()("@test/InterleavedDispose") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => + Effect.promise(async () => { + await Bun.sleep(5) + return ctx.directory + }), + ) + + return Test.of({ + get: Effect.fn("Test.get")(function* () { + return yield* InstanceState.get(state) + }), + }) + }), + ) + } - return Test.of({ - get: Effect.fn("Test.get")(function* () { - return yield* InstanceState.get(state) - }), - }) + yield* Effect.gen(function* () { + const a = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + expect(a).toBe(one) + + const [, b] = yield* Effect.all( + [ + Effect.promise(() => Instance.reload({ directory: one })), + Test.use((svc) => svc.get()).pipe(provideInstance(two)), + ], + { concurrency: "unbounded" }, + ) + expect(b).toBe(two) + + const c = yield* Test.use((svc) => svc.get()).pipe(provideInstance(one)) + expect(c).toBe(one) + }).pipe(Effect.provide(Test.layer)) + }), +) + +it.live("InstanceState mutation in one directory does not leak to another", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped() + const two = yield* tmpdirScoped() + const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + + const s1 = yield* access(state, one) + s1.count = 42 + + const s2 = yield* access(state, two) + expect(s2.count).toBe(0) + + const s1again = yield* access(state, one) + expect(s1again.count).toBe(42) + expect(s1again).toBe(s1) + }), +) + +it.live("InstanceState dedupes concurrent lookups", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + let n = 0 + const state = yield* InstanceState.make(() => + Effect.promise(async () => { + n += 1 + await Bun.sleep(10) + return { n } }), ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - // Init both directories - const a = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - expect(a).toBe(one.path) - - // Dispose one directory, access the other concurrently - const [, b] = await Promise.all([ - Instance.reload({ directory: one.path }), - Instance.provide({ - directory: two.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }), - ]) - expect(b).toBe(two.path) - - // Re-access disposed directory - should get fresh state - const c = await Instance.provide({ - directory: one.path, - fn: () => rt.runPromise(Test.use((svc) => svc.get())), - }) - expect(c).toBe(one.path) - } finally { - await rt.dispose() - } -}) - -test("InstanceState mutation in one directory does not leak to another", async () => { - await using one = await tmpdir() - await using two = await tmpdir() - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 }))) + const [a, b] = yield* Effect.all([access(state, dir), access(state, dir)], { concurrency: "unbounded" }) + expect(a).toBe(b) + expect(n).toBe(1) + }), +) - // Mutate state in directory one - const s1 = yield* Effect.promise(() => access(state, one.path)) - s1.count = 42 +it.live("InstanceState survives deferred resume from the same instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) - // Access directory two — should be independent - const s2 = yield* Effect.promise(() => access(state, two.path)) - expect(s2.count).toBe(0) - - // Confirm directory one still has the mutation - const s1again = yield* Effect.promise(() => access(state, one.path)) - expect(s1again.count).toBe(42) - expect(s1again).toBe(s1) // same reference - }), - ), - ) -}) + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect + } -test("InstanceState dedupes concurrent lookups", async () => { - await using tmp = await tmpdir() - let n = 0 - - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const state = yield* InstanceState.make(() => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - ) + class Test extends Context.Service()("@test/DeferredResume") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } - const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) - expect(a).toBe(b) - expect(n).toBe(1) - }), - ), - ) -}) + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) -test("InstanceState survives deferred resume from the same instance context", async () => { - await using tmp = await tmpdir({ git: true }) + yield* Deferred.succeed(gate, undefined).pipe(provideInstance(dir)) + const exit = yield* Fiber.await(fiber) - interface Api { - readonly get: (gate: Deferred.Deferred) => Effect.Effect - } + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) - class Test extends Context.Service()("@test/DeferredResume") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) +it.live("InstanceState survives deferred resume outside ALS when InstanceRef is set", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) - return Test.of({ - get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { - yield* Deferred.await(gate) - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const gate = await Effect.runPromise(Deferred.make()) - const fiber = await Instance.provide({ - directory: tmp.path, - fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))), - }) - - await Instance.provide({ - directory: tmp.path, - fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)), - }) - const exit = await Effect.runPromise(Fiber.await(fiber)) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value).toBe(tmp.path) + interface Api { + readonly get: (gate: Deferred.Deferred) => Effect.Effect } - } finally { - await rt.dispose() - } -}) -test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => { - await using tmp = await tmpdir({ git: true }) + class Test extends Context.Service()("@test/DeferredResumeOutside") { + static readonly layer = Layer.effect( + Test, + Effect.gen(function* () { + const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + + return Test.of({ + get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { + yield* Deferred.await(gate) + return yield* InstanceState.get(state) + }), + }) + }), + ) + } - interface Api { - readonly get: (gate: Deferred.Deferred) => Effect.Effect - } + yield* Effect.gen(function* () { + const gate = yield* Deferred.make() + const fiber = yield* Test.use((svc) => svc.get(gate)).pipe(provideInstance(dir), Effect.forkScoped) - class Test extends Context.Service()("@test/DeferredResumeOutside") { - static readonly layer = Layer.effect( - Test, - Effect.gen(function* () { - const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory)) + yield* Deferred.succeed(gate, undefined) + const exit = yield* Fiber.await(fiber) - return Test.of({ - get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred) { - yield* Deferred.await(gate) - return yield* InstanceState.get(state) - }), - }) - }), - ) - } - - const rt = ManagedRuntime.make(Test.layer) - - try { - const gate = await Effect.runPromise(Deferred.make()) - // Provide InstanceRef so the fiber carries the context even when - // the deferred is resolved from outside Instance.provide ALS. - const fiber = await Instance.provide({ - directory: tmp.path, - fn: () => - Promise.resolve( - rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))), - ), - }) - - // Resume from outside any Instance.provide — ALS is NOT set here - await Effect.runPromise(Deferred.succeed(gate, void 0)) - const exit = await Effect.runPromise(Fiber.await(fiber)) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value).toBe(tmp.path) - } - } finally { - await rt.dispose() - } -}) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe(dir) + }).pipe(Effect.provide(Test.layer)) + }), +) From e4ac936eb9a7febecfc113138d794a6148bd6831 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:54:53 -0400 Subject: [PATCH 0046/1114] test: use testEffect for plugin workspace adaptor (#25052) --- .../test/plugin/workspace-adaptor.test.ts | 131 +++++++++--------- 1 file changed, 66 insertions(+), 65 deletions(-) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 2695e9b28413..c5b878c69bb6 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -1,8 +1,10 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { afterAll, afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" @@ -11,6 +13,7 @@ const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") const { Instance } = await import("../../src/project/instance") +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES @@ -31,79 +34,77 @@ afterAll(() => { }) describe("plugin.workspace", () => { - test("plugin can install a workspace adaptor", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { + it.live("plugin can install a workspace adaptor", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { const type = `plug-${Math.random().toString(36).slice(2)}` const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "created.json") const space = path.join(dir, "space") - await Bun.write( - file, - [ - "export default async ({ experimental_workspace }) => {", - ` experimental_workspace.register(${JSON.stringify(type)}, {`, - ' name: "plug",', - ' description: "plugin workspace adaptor",', - " configure(input) {", - ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, - " },", - " async create(input) {", - ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, - " },", - " async remove() {},", - " target(input) {", - ' return { type: "local", directory: input.directory }', - " },", - " })", - " return {}", - "}", - "", - ].join("\n"), + yield* Effect.promise(() => + Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adaptor",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ), ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), ), ) - return { mark, space, type } - }, - }) - - const info = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.init() - return Workspace.create({ - type: tmp.extra.type, + const plugin = yield* Plugin.Service + yield* plugin.init() + const info = yield* Effect.promise(() => + Workspace.create({ + type, branch: null, extra: { key: "value" }, projectID: Instance.project.id, - }) - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) + }), + ) - expect(info.type).toBe(tmp.extra.type) - expect(info.name).toBe("plug") - expect(info.branch).toBe("plug/main") - expect(info.directory).toBe(tmp.extra.space) - expect(info.extra).toEqual({ key: "value" }) - expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ - type: tmp.extra.type, - name: "plug", - branch: "plug/main", - directory: tmp.extra.space, - extra: { key: "value" }, - }) - }) + expect(info.type).toBe(type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(yield* Effect.promise(() => Bun.file(mark).text()))).toMatchObject({ + type, + name: "plug", + branch: "plug/main", + directory: space, + extra: { key: "value" }, + }) + }), + ), + ) }) From ec3ab4a00cba54b8aded49996c3539037b4e2622 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 12:55:33 -0400 Subject: [PATCH 0047/1114] test: use testEffect for retry policy (#25050) --- packages/opencode/test/session/retry.test.ts | 165 ++++++++++--------- 1 file changed, 86 insertions(+), 79 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index aa1a29ec1948..105c772d9735 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,28 +2,31 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Schedule } from "effect" +import { Effect, Layer, Schedule } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" import { ProviderID } from "../../src/provider/schema" -import { AppRuntime } from "../../src/effect/app-runtime" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const providerID = ProviderID.make("test") +const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { - return new MessageV2.APIError({ - message: "boom", - isRetryable: true, - responseHeaders: headers, - }).toObject() as MessageV2.APIError + return MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "boom", + isRetryable: true, + responseHeaders: headers, + }).toObject(), + ) } function wrap(message: unknown): ReturnType { - return { data: { message } } as ReturnType + return { name: "", data: { message } } } describe("session.retry.delay", () => { @@ -80,47 +83,36 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY) }) - test("policy updates retry status and increments attempts", async () => { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { + it.live("policy updates retry status and increments attempts", () => + provideTmpdirInstance(() => + Effect.gen(function* () { const sessionID = SessionID.make("session-retry-test") const error = apiError({ "retry-after-ms": "0" }) - - await Effect.runPromise( - Effect.gen(function* () { - const step = yield* Schedule.toStepWithMetadata( - SessionRetry.policy({ - parse: (err) => err as MessageV2.APIError, - set: (info) => - Effect.promise(() => - AppRuntime.runPromise( - SessionStatus.Service.use((svc) => - svc.set(sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), - ), - ), - ), + const status = yield* SessionStatus.Service + + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + parse: (err) => MessageV2.APIError.Schema.parse(err), + set: (info) => + status.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, }), - ) - yield* step(error) - yield* step(error) }), ) + yield* step(error) + yield* step(error) - expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({ + expect(yield* status.get(sessionID)).toMatchObject({ type: "retry", attempt: 2, message: "boom", }) - }, - }) - }) + }), + ), + ) }) describe("session.retry.retryable", () => { @@ -173,58 +165,68 @@ describe("session.retry.retryable", () => { const error = new MessageV2.ContextOverflowError({ message: "Input exceeds context window of this model", responseBody: '{"error":{"code":"context_length_exceeded"}}', - }).toObject() as ReturnType + }).toObject() expect(SessionRetry.retryable(error)).toBeUndefined() }) test("retries 500 errors even when isRetryable is false", () => { - const error = new MessageV2.APIError({ - message: "Internal server error", - isRetryable: false, - statusCode: 500, - responseBody: '{"type":"api_error","message":"Internal server error"}', - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Internal server error", + isRetryable: false, + statusCode: 500, + responseBody: '{"type":"api_error","message":"Internal server error"}', + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Internal server error") }) test("retries 502 bad gateway errors", () => { - const error = new MessageV2.APIError({ - message: "Bad gateway", - isRetryable: false, - statusCode: 502, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Bad gateway", + isRetryable: false, + statusCode: 502, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Bad gateway") }) test("retries 503 service unavailable errors", () => { - const error = new MessageV2.APIError({ - message: "Service unavailable", - isRetryable: false, - statusCode: 503, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Service unavailable", + isRetryable: false, + statusCode: 503, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBe("Service unavailable") }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = new MessageV2.APIError({ - message: "Bad request", - isRetryable: false, - statusCode: 400, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Bad request", + isRetryable: false, + statusCode: 400, + }).toObject(), + ) expect(SessionRetry.retryable(error)).toBeUndefined() }) test("retries ZlibError decompression failures", () => { - const error = new MessageV2.APIError({ - message: "Response decompression failed", - isRetryable: true, - metadata: { code: "ZlibError" }, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Response decompression failed", + isRetryable: true, + metadata: { code: "ZlibError" }, + }).toObject(), + ) const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() @@ -261,20 +263,23 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(error, { providerID }) expect(MessageV2.APIError.isInstance(result)).toBe(true) - expect((result as MessageV2.APIError).data.isRetryable).toBe(true) - expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server") - expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET") - expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection") + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(true) + expect(result.data.message).toBe("Connection reset by server") + expect(result.data.metadata?.code).toBe("ECONNRESET") + expect(result.data.metadata?.message).toInclude("socket connection") }, 15_000, ) test("ECONNRESET socket error is retryable", () => { - const error = new MessageV2.APIError({ - message: "Connection reset by server", - isRetryable: true, - metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, - }).toObject() as MessageV2.APIError + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Connection reset by server", + isRetryable: true, + metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" }, + }).toObject(), + ) const retryable = SessionRetry.retryable(error) expect(retryable).toBeDefined() @@ -291,7 +296,8 @@ describe("session.message-v2.fromError", () => { responseBody: '{"error":"boom"}', isRetryable: false, }) - const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError + const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) }) @@ -313,7 +319,8 @@ describe("session.message-v2.fromError", () => { ) expect(MessageV2.APIError.isInstance(result)).toBe(true) - expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") + expect(result.data.isRetryable).toBe(true) expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") }) }) From f384675c01a44a2516437d9be1bfd25493869ac7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 13:05:10 -0400 Subject: [PATCH 0048/1114] test: use Effect test helper for run-service (#25048) --- .../opencode/test/effect/run-service.test.ts | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index b5f1a1d09bed..e9baf88538fd 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,46 +1,49 @@ -import { expect, test } from "bun:test" +import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" import { makeRuntime } from "../../src/effect/run-service" +import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} -test("makeRuntime shares dependent layers through the shared memo map", async () => { - let n = 0 +it.live("makeRuntime shares dependent layers through the shared memo map", () => + Effect.gen(function* () { + let n = 0 - const shared = Layer.effect( - Shared, - Effect.sync(() => { - n += 1 - return Shared.of({ id: n }) - }), - ) + const shared = Layer.effect( + Shared, + Effect.sync(() => { + n += 1 + return Shared.of({ id: n }) + }), + ) - class One extends Context.Service Effect.Effect }>()("@test/One") {} - const one = Layer.effect( - One, - Effect.gen(function* () { - const svc = yield* Shared - return One.of({ - get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), - }) - }), - ).pipe(Layer.provide(shared)) + class One extends Context.Service Effect.Effect }>()("@test/One") {} + const one = Layer.effect( + One, + Effect.gen(function* () { + const svc = yield* Shared + return One.of({ + get: Effect.fn("One.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) - class Two extends Context.Service Effect.Effect }>()("@test/Two") {} - const two = Layer.effect( - Two, - Effect.gen(function* () { - const svc = yield* Shared - return Two.of({ - get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), - }) - }), - ).pipe(Layer.provide(shared)) + class Two extends Context.Service Effect.Effect }>()("@test/Two") {} + const two = Layer.effect( + Two, + Effect.gen(function* () { + const svc = yield* Shared + return Two.of({ + get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)), + }) + }), + ).pipe(Layer.provide(shared)) - const { runPromise: runOne } = makeRuntime(One, one) - const { runPromise: runTwo } = makeRuntime(Two, two) + const { runPromise: runOne } = makeRuntime(One, one) + const { runPromise: runTwo } = makeRuntime(Two, two) - expect(await runOne((svc) => svc.get())).toBe(1) - expect(await runTwo((svc) => svc.get())).toBe(1) - expect(n).toBe(1) -}) + expect(yield* Effect.promise(() => runOne((svc) => svc.get()))).toBe(1) + expect(yield* Effect.promise(() => runTwo((svc) => svc.get()))).toBe(1) + expect(n).toBe(1) + }), +) From feeebbe7d4ebf1fdaa74cc1aa63e11cb78b97f8a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 13:53:26 -0400 Subject: [PATCH 0049/1114] Preserve workspace context in session HTTP routes (#25136) --- .../instance/httpapi/handlers/session.ts | 330 +++++------------- .../server/routes/instance/httpapi/server.ts | 8 + packages/opencode/src/session/session.ts | 2 +- .../test/server/httpapi-session.test.ts | 62 ++++ 4 files changed, 154 insertions(+), 248 deletions(-) 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 3d88db60db5e..c4def3e7429a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,5 +1,5 @@ import * as InstanceState from "@/effect/instance-state" -import { AppRuntime } from "@/effect/app-runtime" +import { EffectBridge } from "@/effect/bridge" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" @@ -53,6 +53,13 @@ const mapNotFound = (self: Effect.Effect) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const shareSvc = yield* SessionShare.Service + const promptSvc = yield* SessionPrompt.Service + const revertSvc = yield* SessionRevert.Service + const compactSvc = yield* SessionCompaction.Service + const runState = yield* SessionRunState.Service + const agentSvc = yield* Agent.Service + const permissionSvc = yield* Permission.Service const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service @@ -148,14 +155,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + return yield* shareSvc.create(ctx.payload) }) const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: { @@ -175,14 +175,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.remove(ctx.params.sessionID) return true }) @@ -190,60 +183,31 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof UpdatePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - Effect.gen(function* () { - const current = yield* svc.get(ctx.params.sessionID) - if (ctx.payload.title !== undefined) { - yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) - } - if (ctx.payload.permission !== undefined) { - yield* svc.setPermission({ - sessionID: ctx.params.sessionID, - permission: Permission.merge(current.permission ?? [], ctx.payload.permission), - }) - } - if (ctx.payload.time?.archived !== undefined) { - yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) - } - return yield* svc.get(ctx.params.sessionID) - }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + const current = yield* session.get(ctx.params.sessionID) + if (ctx.payload.title !== undefined) { + yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) + } + if (ctx.payload.permission !== undefined) { + yield* session.setPermission({ + sessionID: ctx.params.sessionID, + permission: Permission.merge(current.permission ?? [], ctx.payload.permission), + }) + } + if (ctx.payload.time?.archived !== undefined) { + yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) + } + return yield* session.get(ctx.params.sessionID) }) const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ForkPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => - svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), - ).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }) }) const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe( - Effect.provide(SessionPrompt.defaultLayer), - ), - ), - ), - ) + yield* promptSvc.cancel(ctx.params.sessionID) return true }) @@ -251,98 +215,45 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + yield* promptSvc.command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) return true }) const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(ctx.params.sessionID) - return yield* session.get(ctx.params.sessionID) - }).pipe(Effect.provide(SessionShare.defaultLayer)), - ), - ), - ) + yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + return yield* session.get(ctx.params.sessionID) }) const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { params: { sessionID: SessionID } payload: typeof SummarizePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service - - yield* revert.cleanup(yield* session.get(ctx.params.sessionID)) - const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) - const defaultAgent = yield* agent.defaultAgent() - const currentAgent = - messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent - - yield* compact.create({ - sessionID: ctx.params.sessionID, - agent: currentAgent, - model: { - providerID: ctx.payload.providerID, - modelID: ctx.payload.modelID, - }, - auto: ctx.payload.auto ?? false, - }) - yield* prompt.loop({ sessionID: ctx.params.sessionID }) - }).pipe( - Effect.provide(SessionRevert.defaultLayer), - Effect.provide(SessionCompaction.defaultLayer), - Effect.provide(SessionPrompt.defaultLayer), - Effect.provide(Agent.defaultLayer), - Effect.provide(Session.defaultLayer), - ), - ), - ), - ) + yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID)) + const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) + const defaultAgent = yield* agentSvc.defaultAgent() + const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent + + yield* compactSvc.create({ + sessionID: ctx.params.sessionID, + agent: currentAgent, + model: { + providerID: ctx.payload.providerID, + modelID: ctx.payload.modelID, + }, + auto: ctx.payload.auto ?? false, + }) + yield* promptSvc.loop({ sessionID: ctx.params.sessionID }) return true }) @@ -350,19 +261,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context + const bridge = yield* EffectBridge.make() return HttpServerResponse.stream( Stream.fromEffect( Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), + bridge.promise( + promptSvc.prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + } as unknown as SessionPrompt.PromptInput), ), ), ).pipe( @@ -377,23 +284,23 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context + const bridge = yield* EffectBridge.make() yield* Effect.sync(() => { - Instance.restore(instance, () => { - void AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ).catch((error) => { - log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) - void Bus.publish(Session.Event.Error, { - sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ - message: error instanceof Error ? error.message : String(error), - }).toObject(), - }) - }) - }) + bridge.fork( + promptSvc + .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) + .pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) + void Bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ message: String(error) }).toObject(), + }) + }), + ), + ), + ) }) return HttpApiSchema.NoContent.make() }) @@ -402,111 +309,47 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ShellPayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionPrompt.Service.use((svc) => - svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput), - ).pipe(Effect.provide(SessionPrompt.defaultLayer)), - ), - ), - ) + return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput) }) const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { params: { sessionID: SessionID } payload: typeof RevertPayload.Type }) { - const instance = yield* InstanceState.context - log.info("revert", ctx.payload) - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }) }) const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe( - Effect.provide(SessionRevert.defaultLayer), - ), - ), - ), - ) + return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID }) }) const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { params: { permissionID: PermissionID } payload: typeof PermissionResponsePayload.Type }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Permission.Service.use((svc) => - svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }), - ).pipe(Effect.provide(Permission.defaultLayer)), - ), - ), - ) + yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }) return true }) const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Effect.gen(function* () { - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(ctx.params.sessionID) - yield* session.removeMessage(ctx.params) - }).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* runState.assertNotBusy(ctx.params.sessionID) + yield* session.removeMessage(ctx.params) return true }) const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID; partID: PartID } }) { - const instance = yield* InstanceState.context - yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + yield* session.removePart(ctx.params) return true }) @@ -524,14 +367,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`, ) } - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - AppRuntime.runPromise( - Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)), - ), - ), - ) + return yield* session.updatePart(payload) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d8208c765714..600b4f6087a1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -22,10 +22,14 @@ import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" import { Session } from "@/session/session" +import { SessionCompaction } from "@/session/compaction" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" +import { SessionShare } from "@/share/session" import { Skill } from "@/skill" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -134,6 +138,10 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, + SessionCompaction.defaultLayer, + SessionPrompt.defaultLayer, + SessionRevert.defaultLayer, + SessionShare.defaultLayer, SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 9a50a9a98045..72c4d241eb13 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -589,7 +589,7 @@ export const layer: Layer.Layer = path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, permission: input?.permission, - workspaceID: workspace, + workspaceID: input?.workspaceID ?? workspace, }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 75e4a3ac9b1d..c7d09454367f 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,9 +3,13 @@ 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 { 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 { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -22,6 +26,7 @@ import { it } from "../lib/effect" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function app(experimental = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental @@ -77,6 +82,28 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } +const localAdaptor = (directory: string): WorkspaceAdaptor => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => + Effect.promise(async () => { + registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + return Workspace.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }) + }) + function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } @@ -108,6 +135,7 @@ function withTmp( afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() await resetDatabase() }) @@ -226,6 +254,40 @@ describe("session HttpApi", () => { ), ) + it.live( + "persists selected workspace id when creating a session", + withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer)) + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "session-create-workspace", + directory: path.join(tmp.path, ".workspace-local"), + }) + + const created = yield* requestJson(`${SessionPaths.create}?workspace=${workspace.id}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ title: "workspace session" }), + }) + + expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect( + yield* Effect.sync(() => + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, created.id)) + .get(), + ), + ), + ).toEqual({ workspaceID: workspace.id }) + }), + ), + ) + it.live( "matches legacy archived timestamp validation", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => From 19271fca2d2bcbd45bfc39f73c441db0ec9b94f9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 13:57:25 -0400 Subject: [PATCH 0050/1114] Use workspace service in HTTP routes (#25139) --- .../instance/httpapi/handlers/workspace.ts | 43 ++++++++----------- .../server/routes/instance/httpapi/server.ts | 2 + 2 files changed, 21 insertions(+), 24 deletions(-) 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 9413c865d102..4e76a76a30db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,58 +1,53 @@ import { listAdaptors } from "@/control-plane/adaptors" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" -import { Instance } from "@/project/instance" import { Effect } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { CreatePayload, SessionRestorePayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { + const workspace = yield* Workspace.Service + const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { const instance = yield* InstanceState.context return yield* Effect.promise(() => listAdaptors(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { - return Workspace.list((yield* InstanceState.context).project) + return yield* workspace.list((yield* InstanceState.context).project) }) const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) { const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.create({ - ...ctx.payload, - projectID: instance.project.id, - }), - ), - ) + return yield* workspace + .create({ + ...ctx.payload, + projectID: instance.project.id, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const status = Effect.fn("WorkspaceHttpApi.status")(function* () { - const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id)) - return Workspace.status().filter((item) => ids.has(item.workspaceID)) + const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id)) + return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID)) }) const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id))) + return yield* workspace.remove(ctx.params.id) }) const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { params: { id: Workspace.Info["id"] } payload: typeof SessionRestorePayload.Type }) { - const instance = yield* InstanceState.context - return yield* Effect.promise(() => - Instance.restore(instance, () => - Workspace.sessionRestore({ - workspaceID: ctx.params.id, - sessionID: ctx.payload.sessionID, - }), - ), - ) + return yield* workspace + .sessionRestore({ + workspaceID: ctx.params.id, + sessionID: ctx.payload.sessionID, + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) return handlers diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 600b4f6087a1..caca845be394 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -35,6 +35,7 @@ import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" +import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" @@ -149,6 +150,7 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( Todo.defaultLayer, ToolRegistry.defaultLayer, Vcs.defaultLayer, + Workspace.defaultLayer, Worktree.defaultLayer, Bus.layer, HttpServer.layerServices, From 320527a3e4c9c064d3a3e7ce28a138ad8976e830 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 14:15:50 -0400 Subject: [PATCH 0051/1114] Support multiple Zed selections in TUI context (#25140) --- .../cli/cmd/tui/component/prompt/index.tsx | 111 ++++++++++------ .../src/cli/cmd/tui/context/editor-zed.ts | 66 +++++++-- .../src/cli/cmd/tui/context/editor.ts | 51 +++++-- .../test/cli/tui/editor-context-zed.test.ts | 125 ++++++++++++++---- .../test/cli/tui/editor-context.test.tsx | 14 +- 5 files changed, 278 insertions(+), 89 deletions(-) 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 cd47e917085b..1f93a43947bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { useEvent } from "@tui/context/event" -import { useEditorContext, type EditorSelection } from "@tui/context/editor" +import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" @@ -84,16 +84,30 @@ function fadeColor(color: RGBA, alpha: number) { return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) } -function getEditorSelectionKey(selection: EditorSelection) { - return [ - selection.filePath, - selection.text, - selection.source ?? "", - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - ].join("-") +function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { + return ( + selection.selection.start.line !== selection.selection.end.line || + selection.selection.start.character !== selection.selection.end.character + ) +} + +function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { + if (!hasEditorRangeSelection(selection)) return + if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` + return `#${selection.selection.start.line}-${selection.selection.end.line}` +} + +function formatEditorContext(selection: EditorSelection) { + const selected = selection.ranges.filter(hasEditorRangeSelection) + if (selected.length === 0) + return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` + + const ranges = selected.map((range, index) => { + const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" + return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` + }) + + return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } let stashed: { prompt: PromptInfo; cursor: number } | undefined @@ -125,13 +139,21 @@ export function Prompt(props: PromptProps) { const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) - const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined)) - const editorSelectionLabel = createMemo(() => { - const selection = fileContextEnabled() ? editor.selection()?.selection : undefined + const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() + const editorContext = createMemo(() => { + const selection = fileContextEnabled() ? editor.selection() : undefined if (!selection) return - if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return - if (selection.start.line === selection.end.line) return `#${selection.start.line}` - return `#${selection.start.line}-${selection.end.line}` + return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection + }) + const editorPath = createMemo(() => editorContext()?.filePath) + const editorSelectionLabel = createMemo(() => { + const ranges = editorContext()?.ranges + if (!ranges) return + const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] + if (!first) return + return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] + .filter(Boolean) + .join(" ") }) const editorFileLabel = createMemo(() => { const value = editorPath() @@ -147,6 +169,7 @@ export function Prompt(props: PromptProps) { if (!file) return return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) }) + const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) @@ -163,6 +186,11 @@ export function Prompt(props: PromptProps) { } } + function dismissEditorContext() { + setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) + editor.clearSelection() + } + const textareaKeybindings = useTextareaKeybindings() const fileStyleId = syntax().getStyleId("extmark.file")! @@ -292,6 +320,16 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Remove editor context", + value: "prompt.editor_context.clear", + category: "Prompt", + enabled: Boolean(editorContext()), + onSelect: (dialog) => { + dismissEditorContext() + dialog.clear() + }, + }, { title: "Paste", value: "prompt.paste", @@ -760,35 +798,21 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() - const editorSelection = fileContextEnabled() ? editor.selection() : undefined - const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined + const editorSelection = editorContext() + const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = - editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey + editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey ? [ { id: PartID.ascending(), type: "text" as const, - text: (() => { - const start = editorSelection.selection.start - const end = editorSelection.selection.end - - let text = "" - if (start.line === end.line && start.character === end.character) { - text = `Note: The user opened the file "${editorSelection.filePath}".` - } else if (start.line === end.line) { - text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } else { - text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n` - } - - return `${text} This may or may not be relevant to the current task.\n` - })(), + text: formatEditorContext(editorSelection), synthetic: true, metadata: { kind: "editor_context", source: editorSelection.source ?? "editor", filePath: editorSelection.filePath, - selection: editorSelection.selection, + ranges: editorSelection.ranges, }, }, ] @@ -855,7 +879,7 @@ export function Prompt(props: PromptProps) { ], }) .catch(() => {}) - lastSubmittedEditorSelectionKey = editorSelectionKey + lastSubmittedEditorSelectionKey = currentEditorSelectionKey } history.append({ ...store.prompt, @@ -1406,7 +1430,18 @@ export function Prompt(props: PromptProps) { - {(file) => {file()}} + + {(file) => ( + setEditorContextHover(true)} + onMouseOut={() => setEditorContextHover(false)} + onMouseUp={dismissEditorContext} + > + {editorContextHover() ? `x ${file()}` : file()} + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 00f90857c025..5b7bf1cf4a31 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({ workspace_paths: z.string().nullable(), timestamp: z.string(), buffer_path: z.string().nullable(), +}) + +const ZedSelectionRowSchema = z.object({ selection_start: z.number().nullable(), selection_end: z.number().nullable(), }) @@ -24,6 +27,7 @@ const utf8 = new TextEncoder() type ZedEditorRow = z.infer type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } +type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -36,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): const row = active.row if (!row.buffer_path) return { type: "empty" } - if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" } + + const selections = queryZedEditorSelections(dbPath, row) + if (selections.type !== "selections") return selections + const byteRanges = selections.selections + .flatMap((selection) => { + if (selection.selection_start == null || selection.selection_end == null) return [] + return [ + { + start: Math.min(selection.selection_start, selection.selection_end), + end: Math.max(selection.selection_start, selection.selection_end), + }, + ] + }) + .sort((left, right) => left.start - right.start || left.end - right.end) + if (byteRanges.length === 0) return { type: "unavailable" } const contents = queryZedEditorContents(dbPath, row) const text = @@ -47,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): .catch(() => undefined) if (text == null) return { type: "unavailable" } - const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end)) - const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end)) + const ranges = byteRanges.map((range) => { + const startOffset = utf8ByteOffsetToStringIndex(text, range.start) + const endOffset = utf8ByteOffsetToStringIndex(text, range.end) + return { + text: text.slice(startOffset, endOffset), + selection: offsetsToSelection(text, startOffset, endOffset), + } + }) return { type: "selection", selection: { - text: text.slice(startOffset, endOffset), filePath: row.buffer_path, source: "zed", - selection: offsetsToSelection(text, startOffset, endOffset), + ranges, }, } } @@ -73,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { i.workspace_id as workspace_id, w.paths as workspace_paths, w.timestamp as timestamp, - e.buffer_path as buffer_path, - s.start as selection_start, - s.end as selection_end + e.buffer_path as buffer_path from items i join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id join workspaces w on w.workspace_id = i.workspace_id left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id - left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id where i.active = 1 and p.active = 1 order by w.timestamp desc`, ) @@ -108,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { } } +function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { + let db: Database | undefined + try { + db = new Database(dbPath, { readonly: true }) + const raw = db + .query( + `select + start as selection_start, + end as selection_end + from editor_selections + where editor_id = $editorID and workspace_id = $workspaceID`, + ) + .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) + + const selections = raw.flatMap((selection) => { + const parsed = ZedSelectionRowSchema.safeParse(selection) + return parsed.success ? [parsed.data] : [] + }) + + if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } + return { type: "selections" as const, selections } + } catch { + return { type: "unavailable" as const } + } finally { + db?.close() + } +} + function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 4ebc1c2c06c0..06dd6fd0421e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -28,16 +28,46 @@ const PositionSchema = z.object({ character: z.number(), }) -const EditorSelectionSchema = z.object({ +const EditorSelectionRangeSchema = z.object({ text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), selection: z.object({ start: PositionSchema, end: PositionSchema, }), }) +const EditorSelectionSchema = z + .union([ + z.object({ + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + ranges: z.array(EditorSelectionRangeSchema).min(1), + }), + z.object({ + text: z.string(), + filePath: z.string(), + source: z.enum(["websocket", "zed"]).optional(), + selection: z.object({ + start: PositionSchema, + end: PositionSchema, + }), + }), + ]) + .transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ) + const EditorMentionSchema = z.object({ filePath: z.string(), lineStart: z.number(), @@ -262,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return store.selection }, clearSelection() { + lastZedSelectionKey = undefined setStore("selection", undefined) }, onMention(listener: (mention: EditorMention) => void) { @@ -352,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined { } } -function editorSelectionKey(selection: EditorSelection | undefined) { +export function editorSelectionKey(selection: EditorSelection | undefined) { if (!selection) return "" return [ selection.filePath, - selection.selection.start.line, - selection.selection.start.character, - selection.selection.end.line, - selection.selection.end.character, - selection.text, + ...selection.ranges.flatMap((range) => [ + range.selection.start.line, + range.selection.start.character, + range.selection.end.line, + range.selection.end.character, + range.text, + ]), ].join("\0") } diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 4c5491461e4e..9a9bca8c5e3c 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -10,6 +10,7 @@ type ZedFixtureOptions = { editor?: boolean selectionStart?: number | null selectionEnd?: number | null + selections?: Array<{ start: number | null; end: number | null }> contents?: string } @@ -30,10 +31,16 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) { db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"]) if (options.editor !== false) { db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents]) - db.run("insert into editor_selections values (1, 1, ?, ?)", [ - options.selectionStart === undefined ? 4 : options.selectionStart, - options.selectionEnd === undefined ? 7 : options.selectionEnd, - ]) + ;( + options.selections ?? [ + { + start: options.selectionStart === undefined ? 4 : options.selectionStart, + end: options.selectionEnd === undefined ? 7 : options.selectionEnd, + }, + ] + ).forEach((selection) => + db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]), + ) } db.close() @@ -66,13 +73,59 @@ test("resolveZedSelection returns active editor selection", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "two", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 4 }, + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection returns all active editor selections sorted by offset", async () => { + await using tmp = await tmpdir() + const contents = "one\ntwo\nthree\nfour" + const fixture = await writeZedFixture(tmp.path, { + contents, + selections: [ + { + start: utf8ByteOffset(contents, contents.indexOf("four")), + end: utf8ByteOffset(contents, contents.indexOf("four") + 4), + }, + { + start: utf8ByteOffset(contents, contents.indexOf("two")), + end: utf8ByteOffset(contents, contents.indexOf("two") + 3), }, + ], + }) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + { + text: "four", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 5 }, + }, + }, + ], }, }) }) @@ -90,13 +143,17 @@ test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", as expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 4, character: 1 }, - end: { line: 4, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 4, character: 1 }, + end: { line: 4, character: 7 }, + }, + }, + ], }, }) }) @@ -114,13 +171,17 @@ test("resolveZedSelection handles non-ASCII text inside the selected range", asy expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "выбор", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 6 }, - }, + ranges: [ + { + text: "выбор", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 6 }, + }, + }, + ], }, }) }) @@ -138,13 +199,17 @@ test("resolveZedSelection handles emoji before the selected range", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 2, character: 1 }, - end: { line: 2, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 7 }, + }, + }, + ], }, }) }) @@ -162,13 +227,17 @@ test("resolveZedSelection handles reversed Zed byte offsets", async () => { expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "selection", selection: { - text: "TARGET", filePath: fixture.filePath, source: "zed", - selection: { - start: { line: 3, character: 1 }, - end: { line: 3, character: 7 }, - }, + ranges: [ + { + text: "TARGET", + selection: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 7 }, + }, + }, + ], }, }) }) diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index e896c29fb5b9..14dead86ac79 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -190,13 +190,17 @@ test("useEditorContext resets selection when reconnecting", async () => { serverInfo: { name: "test", version: "0.0.0" }, }) expect(mounted.editor.selection()).toEqual({ - text: "foo", filePath: path.join(startupDirectory, "file.ts"), source: "websocket", - selection: { - start: { line: 1, character: 1 }, - end: { line: 1, character: 4 }, - }, + ranges: [ + { + text: "foo", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + }, + }, + ], }) mounted.editor.reconnect(startupDirectory) From f4ce240a2ed419bba49dd1ff1326c036ba53b2ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 14:24:43 -0400 Subject: [PATCH 0052/1114] Use PTY service directly in HTTP routes (#25138) --- packages/opencode/src/pty/index.ts | 65 +++++----- .../routes/instance/httpapi/handlers/pty.ts | 112 +++++++++--------- .../opencode/test/server/httpapi-pty.test.ts | 89 +++++++++++++- 3 files changed, 175 insertions(+), 91 deletions(-) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 2518800ce811..ade4b5d02ec5 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -5,7 +5,6 @@ import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import { lazy } from "@opencode-ai/core/util/lazy" import { Plugin } from "@/plugin" -import { Instance } from "@/project/instance" import { Shell } from "@/shell/shell" import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" @@ -229,42 +228,38 @@ export const layer = Layer.effect( subscribers: new Map(), } s.sessions.set(id, session) - proc.onData( - Instance.bind((chunk) => { - session.cursor += chunk.length - - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1) { - session.subscribers.delete(key) - continue - } - if (sock(ws) !== key) { - session.subscribers.delete(key) - continue - } - try { - ws.send(chunk) - } catch { - session.subscribers.delete(key) - } + proc.onData((chunk) => { + session.cursor += chunk.length + + for (const [key, ws] of session.subscribers.entries()) { + if (ws.readyState !== 1) { + session.subscribers.delete(key) + continue + } + if (sock(ws) !== key) { + session.subscribers.delete(key) + continue + } + try { + ws.send(chunk) + } catch { + session.subscribers.delete(key) } + } - session.buffer += chunk - if (session.buffer.length <= BUFFER_LIMIT) return - const excess = session.buffer.length - BUFFER_LIMIT - session.buffer = session.buffer.slice(excess) - session.bufferCursor += excess - }), - ) - proc.onExit( - Instance.bind(({ exitCode }) => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - bridge.fork(bus.publish(Event.Exited, { id, exitCode })) - bridge.fork(remove(id)) - }), - ) + session.buffer += chunk + if (session.buffer.length <= BUFFER_LIMIT) return + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess + }) + proc.onExit(({ exitCode }) => { + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(remove(id)) + }) yield* bus.publish(Event.Created, { info }) return info }) 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 aa151cecec06..cc7c385b3eb8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,4 +1,3 @@ -import { EffectBridge } from "@/effect/bridge" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { handlePtyInput } from "@/pty/input" @@ -23,16 +22,11 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }) const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { - const bridge = yield* EffectBridge.make() - return yield* Effect.promise(() => - bridge.promise( - pty.create({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - ), - ) + return yield* pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + env: ctx.payload.env ? { ...ctx.payload.env } : undefined, + }) }) const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { @@ -68,52 +62,60 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }), ) -export const ptyConnectRoute = HttpRouter.add( - "GET", - PtyPaths.connect, +export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service - const params = yield* HttpRouter.schemaPathParams(Params) - if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - - const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) - 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 write = yield* socket.writer - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - Effect.runFork( - write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)), - ) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void))) - }, - } - const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() + yield* router.add( + "GET", + PtyPaths.connect, + Effect.gen(function* () { + const params = yield* HttpRouter.schemaPathParams(Params) + if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) - yield* socket - .runRaw((message) => handlePtyInput(handler, message)) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { + const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + 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 write = yield* socket.writer + const services = yield* Effect.context() + const writeScoped = (effect: Effect.Effect) => { + Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + } + let closed = false + const adapter = { + get readyState() { + return closed ? 3 : 1 + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + if (closed) return + writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data)) + }, + close: (code?: number, reason?: string) => { + if (closed) return closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) - return HttpServerResponse.empty() - }).pipe(Effect.provide(Pty.defaultLayer)), + writeScoped(write(new Socket.CloseEvent(code, reason))) + }, + } + const handler = yield* pty.connect(params.ptyID, adapter, cursor) + if (!handler) return HttpServerResponse.empty() + + yield* socket + .runRaw((message) => handlePtyInput(handler, message)) + .pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring( + Effect.sync(() => { + closed = true + handler.onClose() + }), + ), + Effect.orDie, + ) + return HttpServerResponse.empty() + }), + ) + }), ) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 37d2a4f64d92..e4d22427cb45 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" @@ -6,18 +7,60 @@ 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 } from "../fixture/fixture" +import { 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" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { Pty } from "../../src/pty" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + ExperimentalHttpApiServer.routes, + { disableListenLog: true, disableLogger: true }, +) + +const effectIt = testEffect( + Layer.mergeAll( + testStateLayer, + Socket.layerWebSocketConstructorGlobal, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + function app() { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } +function serverUrl() { + return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) +} + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await Instance.disposeAll() @@ -85,4 +128,48 @@ describe("pty HttpApi bridge", () => { }) expect(response.status).toBe(404) }) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "serves PTY websocket output and input through Effect routes", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const created = yield* HttpClientRequest.post(PtyPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json) + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + yield* write("ping-route\n") + expect(yield* takeUntil("ping-route")).toContain("ping-route") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + + const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(200) + }), + ) }) From 87cd9446d8016f8b8d97915de3008b22f047a57d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 14:24:53 -0400 Subject: [PATCH 0053/1114] test: use testEffect for plugin triggers (#25053) --- packages/opencode/test/plugin/trigger.test.ts | 142 ++++++++---------- 1 file changed, 64 insertions(+), 78 deletions(-) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 972ba61b1446..5e16af42be75 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,18 +1,18 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" +import { afterAll, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") -const { Instance } = await import("../../src/project/instance") - -afterEach(async () => { - await Instance.disposeAll() -}) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const systemHook = "experimental.chat.system.transform" afterAll(() => { if (disableDefault === undefined) { @@ -22,95 +22,81 @@ afterAll(() => { process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault }) -async function project(source: string) { - return tmpdir({ - init: async (dir) => { +function withProject(source: string, self: Effect.Effect) { + return provideTmpdirInstance((dir) => + Effect.gen(function* () { const file = path.join(dir, "plugin.ts") - await Bun.write(file, source) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ), + ], + { discard: true, concurrency: 2 }, ) - }, - }) + return yield* self + }), + ) } +const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () { + const plugin = yield* Plugin.Service + const out = { system: [] as string[] } + yield* plugin.trigger( + systemHook, + { + model: { + providerID: ProviderID.anthropic, + modelID: ModelID.make("claude-sonnet-4-6"), + }, + }, + out, + ) + return out.system +}) + describe("plugin.trigger", () => { - test("runs synchronous hooks without crashing", async () => { - await using tmp = await project( + it.live("runs synchronous hooks without crashing", () => + withProject( [ "export default async () => ({", - ' "experimental.chat.system.transform": (_input, output) => {', + ` ${JSON.stringify(systemHook)}: (_input, output) => {`, ' output.system.unshift("sync")', " },", "})", "", ].join("\n"), - ) - - const out = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const out = { system: [] as string[] } - yield* plugin.trigger( - "experimental.chat.system.transform", - { - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - } as any, - }, - out, - ) - return out - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) - - expect(out.system).toEqual(["sync"]) - }) + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["sync"]) + }), + ), + ) - test("awaits asynchronous hooks", async () => { - await using tmp = await project( + it.live("awaits asynchronous hooks", () => + withProject( [ "export default async () => ({", - ' "experimental.chat.system.transform": async (_input, output) => {', + ` ${JSON.stringify(systemHook)}: async (_input, output) => {`, " await Bun.sleep(1)", ' output.system.unshift("async")', " },", "})", "", ].join("\n"), - ) - - const out = await Instance.provide({ - directory: tmp.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - const out = { system: [] as string[] } - yield* plugin.trigger( - "experimental.chat.system.transform", - { - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - } as any, - }, - out, - ) - return out - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) - - expect(out.system).toEqual(["async"]) - }) + Effect.gen(function* () { + expect(yield* triggerSystemTransform()).toEqual(["async"]) + }), + ), + ) }) From cedff6fb89f1c2d4e3b74803b72304b30d37c079 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 15:10:30 -0400 Subject: [PATCH 0054/1114] Isolate TUI thread cwd resolution test (#25147) --- packages/opencode/src/cli/cmd/tui/thread.ts | 11 +- packages/opencode/test/cli/tui/thread.test.ts | 125 +----------------- 2 files changed, 13 insertions(+), 123 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 07f9107b61eb..384b6fc4ff57 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -71,6 +71,12 @@ async function input(value?: string) { return piped + "\n" + value } +export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) { + const root = Filesystem.resolve(envPWD ?? cwd) + if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project)) + return Filesystem.resolve(cwd) +} + export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", @@ -124,10 +130,7 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + const next = resolveThreadDirectory(args.project) const file = await target() try { process.chdir(next) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index b7435565564d..53b7488c2682 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -1,141 +1,28 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { tmpdir } from "../../fixture/fixture" -import * as App from "../../../src/cli/cmd/tui/app" -import { UI } from "../../../src/cli/ui" -import * as Timeout from "../../../src/util/timeout" -import * as Network from "../../../src/cli/network" -import * as Win32 from "../../../src/cli/cmd/tui/win32" - -const stop = new Error("stop") -const packageRoot = path.resolve(import.meta.dir, "../../..") -const seen = { - tui: [] as string[], -} - -class TestWorker extends EventTarget { - onerror: Worker["onerror"] = null - onmessage: Worker["onmessage"] = null - onmessageerror: Worker["onmessageerror"] = null - - postMessage(data: string) { - const parsed = JSON.parse(data) - if (!parsed || typeof parsed !== "object" || !("method" in parsed) || !("id" in parsed)) return - if (typeof parsed.method !== "string" || typeof parsed.id !== "number") return - const result = - parsed.method === "fetch" - ? { status: 200, headers: {}, body: "" } - : parsed.method === "server" - ? { url: "http://127.0.0.1" } - : parsed.method === "snapshot" - ? "" - : undefined - queueMicrotask(() => { - this.onmessage?.( - new MessageEvent("message", { data: JSON.stringify({ type: "rpc.result", result, id: parsed.id }) }), - ) - }) - } - - terminate() {} -} - -function setup() { - // Intentionally avoid mock.module() here: Bun keeps module overrides in cache - // and mock.restore() does not reset mock.module values. If this switches back - // to module mocks, later suites can see mocked @/config/tui and fail (e.g. - // plugin-loader tests expecting real TuiConfig.waitForDependencies). See: - // https://github.com/oven-sh/bun/issues/7823 and #12823. - spyOn(App, "tui").mockImplementation(async (input) => { - if (input.directory) seen.tui.push(input.directory) - throw stop - }) - spyOn(UI, "error").mockImplementation(() => {}) - spyOn(Timeout, "withTimeout").mockImplementation((input) => input) - spyOn(Network, "resolveNetworkOptions").mockResolvedValue({ - mdns: false, - port: 0, - hostname: "127.0.0.1", - mdnsDomain: "opencode.local", - cors: [], - }) - spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {}) - spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined) -} +import { resolveThreadDirectory } from "../../../src/cli/cmd/tui/thread" describe("tui thread", () => { - afterEach(() => { - mock.restore() - }) - - async function call(project?: string) { - const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") - const args: Parameters>[0] = { - _: [], - $0: "opencode", - project, - prompt: "hi", - model: undefined, - agent: undefined, - session: undefined, - continue: false, - fork: false, - port: 0, - hostname: "127.0.0.1", - mdns: false, - "mdns-domain": "opencode.local", - mdnsDomain: "opencode.local", - cors: [], - } - return TuiThreadCommand.handler(args) - } - async function check(project?: string) { - setup() - const pwd = process.env.PWD - const worker = globalThis.Worker - const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" - seen.tui.length = 0 - await fs.symlink(tmp.path, link, type) - - Object.defineProperty(process.stdin, "isTTY", { - configurable: true, - value: true, - }) - Object.defineProperty(globalThis, "Worker", { configurable: true, value: TestWorker }) try { - process.chdir(tmp.path) - process.env.PWD = link - let error: unknown - try { - await call(project) - } catch (caught) { - error = caught - } - expect(error).toBe(stop) - expect(seen.tui[0]).toBe(tmp.path) + await fs.symlink(tmp.path, link, type) + expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path) } finally { - process.chdir(packageRoot) - if (pwd === undefined) delete process.env.PWD - else process.env.PWD = pwd - if (tty) Object.defineProperty(process.stdin, "isTTY", tty) - else delete (process.stdin as { isTTY?: boolean }).isTTY - Object.defineProperty(globalThis, "Worker", { configurable: true, value: worker }) await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) } } - // serial because both modify real env vars - test.serial("uses the real cwd when PWD points at a symlink", async () => { + test("uses the real cwd when PWD points at a symlink", async () => { await check() }) - test.serial("uses the real cwd after resolving a relative project from PWD", async () => { + test("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) }) From b315a70773c064fcd3537b770d86e8c6bd3bcd6b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 15:14:25 -0400 Subject: [PATCH 0055/1114] test: use Effect test helper for agent colors (#25051) --- .../opencode/test/config/agent-color.test.ts | 94 +++++++++---------- 1 file changed, 43 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index fd3d73fd17c4..49509156ab7e 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,67 +1,59 @@ import { test, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "@/util/color" import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" -const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) -const agent = (dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect) => - Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer))) +const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) -test("agent color parsed from project config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - build: { color: "#FFA500" }, - plan: { color: "primary" }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const cfg = await load() +const writeConfig = (dir: string, agent: Config.Info["agent"]) => + Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent, + }), + ), + ) + +it.live("agent color parsed from project config", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeConfig(dir, { + build: { color: "#FFA500" }, + plan: { color: "primary" }, + }) + + yield* Effect.gen(function* () { + const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))) expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") - }, - }) -}) + }).pipe(provideInstance(dir)) + }), +) -test("Agent.get includes color from config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - plan: { color: "#A855F7" }, - build: { color: "accent" }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const plan = await agent(tmp.path, (svc) => svc.get("plan")) +it.live("Agent.get includes color from config", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeConfig(dir, { + plan: { color: "#A855F7" }, + build: { color: "accent" }, + }) + + yield* Effect.gen(function* () { + const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan")) expect(plan?.color).toBe("#A855F7") - const build = await agent(tmp.path, (svc) => svc.get("build")) + const build = yield* AgentSvc.Service.use((svc) => svc.get("build")) expect(build?.color).toBe("accent") - }, - }) -}) + }).pipe(provideInstance(dir)) + }), +) test("Color.hexToAnsiBold converts valid hex to ANSI", () => { const result = Color.hexToAnsiBold("#FFA500") From 0e9d9282c605372927eee353e1f3bf87c57b7484 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 15:34:37 -0400 Subject: [PATCH 0056/1114] Refactor workspace service boundaries (#25152) --- .../opencode/src/control-plane/workspace.ts | 40 -- packages/opencode/src/server/fence.ts | 26 +- packages/opencode/src/server/proxy.ts | 18 +- .../src/server/routes/control/workspace.ts | 37 +- .../routes/instance/httpapi/handlers/sync.ts | 11 +- .../httpapi/middleware/workspace-routing.ts | 57 ++- .../src/server/routes/instance/sync.ts | 7 +- packages/opencode/src/server/workspace.ts | 6 +- .../test/control-plane/workspace.test.ts | 130 +++--- .../test/plugin/workspace-adaptor.test.ts | 17 +- .../server/httpapi-instance-context.test.ts | 15 +- .../test/server/httpapi-session.test.ts | 16 +- .../server/httpapi-workspace-routing.test.ts | 8 +- .../test/server/httpapi-workspace.test.ts | 438 +++++++++--------- 14 files changed, 421 insertions(+), 405 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 870bdba50038..fe8046ba9cad 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,6 +1,5 @@ import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" -import { fn } from "@/util/fn" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" @@ -24,7 +23,6 @@ import { Session } from "@/session/session" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" -import { makeRuntime } from "@/effect/run-service" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { NonNegativeInt, withStatics } from "@/util/schema" @@ -857,42 +855,4 @@ function route(url: string | URL, path: string) { return next } -const { runPromise, runSync } = makeRuntime(Service, defaultLayer) - -export const create = fn(CreateInput.zod, (input) => runPromise((svc) => svc.create(input))) - -export const sessionRestore = fn(SessionRestoreInput.zod, (input) => runPromise((svc) => svc.sessionRestore(input))) - -export function list(project: Project.Info) { - return Database.use((db) => - db - .select() - .from(WorkspaceTable) - .where(eq(WorkspaceTable.project_id, project.id)) - .all() - .map(fromRow) - .sort((a, b) => a.id.localeCompare(b.id)), - ) -} - -export const get = fn(WorkspaceID.zod, (id) => runPromise((svc) => svc.get(id))) - -export const remove = fn(WorkspaceID.zod, (id) => runPromise((svc) => svc.remove(id))) - -export function status() { - return runSync((svc) => svc.status()) -} - -export function isSyncing(workspaceID: WorkspaceID) { - return runSync((svc) => svc.isSyncing(workspaceID)) -} - -export function waitForSync(workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) { - return runPromise((svc) => svc.waitForSync(workspaceID, state, signal)) -} - -export function startWorkspaceSyncing(projectID: ProjectID) { - void runPromise((svc) => svc.startWorkspaceSyncing(projectID)) -} - export * as Workspace from "./workspace" diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index ce9a9dba644f..aa784c90df48 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -5,6 +5,8 @@ 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" const HEADER = "x-opencode-sync" type State = Record @@ -54,18 +56,24 @@ export function parse(headers: Headers) { ) as State } -export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - log.info("waiting for state", { - workspaceID, - state, - }) - await Workspace.waitForSync(workspaceID, state, signal) - log.info("state fully synced", { - workspaceID, - 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)) +} + 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 8541d39f4959..051d64c24db0 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -4,6 +4,7 @@ import * as Log from "@opencode-ai/core/util/log" import * as Fence from "./fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" import { ProxyUtil } from "./proxy-util" import { Effect, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -69,18 +70,17 @@ function statusText(response: unknown) { } export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - if (!Workspace.isSyncing(workspaceID)) { - return Effect.succeed( - new Response(`broken sync connection for workspace: ${workspaceID}`, { + return Effect.gen(function* () { + const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID)) + if (!syncing) { + return new Response(`broken sync connection for workspace: ${workspaceID}`, { status: 503, headers: { "content-type": "text/plain; charset=utf-8", }, - }), - ) - } + }) + } - return Effect.gen(function* () { const response = yield* HttpClient.execute( HttpClientRequest.make(req.method as never)(url, { headers: ProxyUtil.headers(req, extra), @@ -100,7 +100,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re next.delete("content-encoding") next.delete("content-length") - if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal)) + if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal) const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) return new Response(body, { status: response.status, @@ -114,7 +114,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re } export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return Effect.runPromise(httpEffect(url, extra, req, workspaceID)) + return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID)) } export function websocket( diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 19fbc757fbdb..08f926d40deb 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -1,8 +1,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 { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" import { WorkspaceAdaptorEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" @@ -62,10 +64,14 @@ export const WorkspaceRoutes = lazy(() => ), async (c) => { const body = c.req.valid("json") as Omit - const workspace = await Workspace.create({ - projectID: Instance.project.id, - ...body, - }) + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => + svc.create({ + projectID: Instance.project.id, + ...body, + }), + ), + ) return c.json(workspace) }, ) @@ -87,7 +93,7 @@ export const WorkspaceRoutes = lazy(() => }, }), async (c) => { - return c.json(Workspace.list(Instance.project)) + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) }, ) .get( @@ -108,8 +114,11 @@ export const WorkspaceRoutes = lazy(() => }, }), async (c) => { - const ids = new Set(Workspace.list(Instance.project).map((item) => item.id)) - return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID))) + const result = await AppRuntime.runPromise( + Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])), + ) + const ids = new Set(result[0].map((item) => item.id)) + return c.json(result[1].filter((item) => ids.has(item.workspaceID))) }, ) .delete( @@ -138,7 +147,7 @@ export const WorkspaceRoutes = lazy(() => ), async (c) => { const { id } = c.req.valid("param") - return c.json(await Workspace.remove(id)) + return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id)))) }, ) .post( @@ -174,10 +183,14 @@ export const WorkspaceRoutes = lazy(() => directory: Instance.directory, }) try { - const result = await Workspace.sessionRestore({ - workspaceID: id, - ...body, - }) + 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, 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 3ae091484f6e..2ff4177f3119 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,4 +1,4 @@ -import { startWorkspaceSyncing } from "@/control-plane/workspace" +import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" @@ -9,15 +9,20 @@ import { eq } from "drizzle-orm" import { lte } from "drizzle-orm" import { not } from "drizzle-orm" import { or } from "drizzle-orm" -import { Effect } from "effect" +import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { HistoryPayload, ReplayPayload } from "../groups/sync" export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => Effect.gen(function* () { + const workspace = yield* Workspace.Service + const scope = yield* Scope.Scope + const start = Effect.fn("SyncHttpApi.start")(function* () { - startWorkspaceSyncing((yield* InstanceState.context).project.id) + yield* workspace + .startWorkspaceSyncing((yield* InstanceState.context).project.id) + .pipe(Effect.ignore, Effect.forkIn(scope)) return true }) 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 68dc0b9d7fd5..ce384ad18c75 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 @@ -75,9 +75,9 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, function resolveWorkspace( id: WorkspaceID | undefined, envWorkspaceID: WorkspaceID | undefined, -): Effect.Effect { +): Effect.Effect { if (!id || envWorkspaceID) return Effect.void - return Effect.promise(() => Workspace.get(id)) + return Workspace.Service.use((workspace) => workspace.get(id)) } function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse { @@ -99,9 +99,9 @@ function proxyRemote( workspace: Workspace.Info, target: RemoteTarget, url: URL, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const syncing = yield* Effect.sync(() => Workspace.isSyncing(workspace.id)) + const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id)) if (!syncing) { return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { status: 503, @@ -113,10 +113,17 @@ function proxyRemote( if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) const sync = Fence.parse(new Headers(response.headers)) - if (sync) - yield* Effect.promise(() => - Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined), + if (sync) { + const syncFailure = yield* Fence.waitEffect( + workspace.id, + sync, + request.source instanceof Request ? request.source.signal : undefined, + ).pipe( + Effect.as(undefined), + Effect.catch((error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 503 }))), ) + if (syncFailure) return syncFailure + } return response }) } @@ -125,7 +132,7 @@ function planWorkspaceRequest( request: HttpServerRequest.HttpServerRequest, url: URL, workspace: Workspace.Info, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const target = yield* resolveTarget(workspace) if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url }) @@ -136,7 +143,7 @@ function planWorkspaceRequest( function planRequest( request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const url = requestURL(request) const envWorkspaceID = configuredWorkspaceID() @@ -158,7 +165,7 @@ function planRequest( function routeWorkspace( effect: Effect.Effect, plan: RequestPlan, -): Effect.Effect { +): Effect.Effect { return RequestPlan.$match(plan, { MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), @@ -167,20 +174,12 @@ function routeWorkspace( }) } -function routeWorkspaceRequest( - effect: Effect.Effect, - request: HttpServerRequest.HttpServerRequest, - sessionWorkspaceID?: WorkspaceID, -): Effect.Effect { - return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan)) -} - function routeHttpApiWorkspace( effect: Effect.Effect, ): Effect.Effect< HttpServerResponse.HttpServerResponse, E, - Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor + Session.Service | Workspace.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor > { return Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest @@ -188,7 +187,8 @@ function routeHttpApiWorkspace( const session = sessionID ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) : undefined - return yield* routeWorkspaceRequest(effect, request, session?.workspaceID) + const plan = yield* planRequest(request, session?.workspaceID) + return yield* routeWorkspace(effect, plan) }) } @@ -196,8 +196,12 @@ export const workspaceRoutingLayer = Layer.effect( WorkspaceRoutingMiddleware, Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor + const workspace = yield* Workspace.Service return WorkspaceRoutingMiddleware.of((effect) => - routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)), + routeHttpApiWorkspace(effect).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + Effect.provideService(Workspace.Service, workspace), + ), ) }), ) @@ -205,12 +209,15 @@ export const workspaceRoutingLayer = Layer.effect( export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()( Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor + const workspace = yield* Workspace.Service return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* routeWorkspaceRequest(effect, request).pipe( - Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), - ) - }) + const plan = yield* planRequest(request) + return yield* routeWorkspace(effect, plan) + }).pipe( + Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), + Effect.provideService(Workspace.Service, workspace), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b480477774db..bb816ecc4225 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -12,7 +12,8 @@ import { eq } from "drizzle-orm" import { EventTable } from "@/sync/event.sql" import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" -import { startWorkspaceSyncing } from "@/control-plane/workspace" +import { Workspace } from "@/control-plane/workspace" +import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" @@ -46,7 +47,9 @@ export const SyncRoutes = lazy(() => }, }), async (c) => { - startWorkspaceSyncing(Instance.project.id) + void AppRuntime.runPromise( + Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)), + ) return c.json(true) }, ) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index c22a09bda9a1..667e610abc3c 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -72,7 +72,9 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + const workspace = await AppRuntime.runPromise( + Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))), + ) if (!workspace) { return new Response(`Workspace not found: ${workspaceID}`, { @@ -89,7 +91,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const adaptor = await getAdaptor(workspace.projectID, workspace.type) + const adaptor = getAdaptor(workspace.projectID, workspace.type) const target = await adaptor.target(workspace) if (target.type === "local") { diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index bd5c4df7d54a..6e68730a9061 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -107,6 +107,24 @@ 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 listWorkspaces = (project: Parameters[0]) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) +const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) +const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id))) +const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status())) +const isWorkspaceSyncing = (id: WorkspaceID) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id))) +const startWorkspaceSyncing = (projectID: ProjectID) => { + void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) +} +const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) + function captureGlobalEvents() { const events: GlobalEvent[] = [] const handler = (event: GlobalEvent) => events.push(event) @@ -372,12 +390,12 @@ describe("workspace-old schemas and exports", () => { describe("workspace-old CRUD", () => { test("get returns undefined for a missing workspace", async () => { await withInstance(async () => { - expect(await WorkspaceOld.get(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() + expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() }) }) test("list maps database rows, filters by project, and sorts by id", async () => { - await withInstance(() => { + await withInstance(async () => { const otherProjectID = ProjectID.make("project-other") insertProject(otherProjectID, "/tmp/other") const a = workspaceInfo(Instance.project.id, "manual", { @@ -397,7 +415,7 @@ describe("workspace-old CRUD", () => { insertWorkspace(other) insertWorkspace(a) - expect(WorkspaceOld.list(Instance.project)).toEqual([a, b]) + expect(await listWorkspaces(Instance.project)).toEqual([a, b]) }) }) @@ -430,7 +448,7 @@ describe("workspace-old CRUD", () => { }) registerAdaptor(Instance.project.id, type, recorded.adaptor) - const info = await WorkspaceOld.create({ + const info = await createWorkspace({ id: workspaceID, type, branch: null, @@ -447,8 +465,8 @@ describe("workspace-old CRUD", () => { extra: { configured: true }, projectID: Instance.project.id, }) - expect(await WorkspaceOld.get(workspaceID)).toEqual(info) - expect(WorkspaceOld.list(Instance.project)).toEqual([info]) + expect(await getWorkspace(workspaceID)).toEqual(info) + expect(await listWorkspaces(Instance.project)).toEqual([info]) expect(recorded.calls.configure).toHaveLength(1) expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) expect(recorded.calls.create).toHaveLength(1) @@ -461,10 +479,10 @@ describe("workspace-old CRUD", () => { expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_HEADERS).toBe("authorization=otel") expect(recorded.calls.create[0].env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.test") expect(recorded.calls.create[0].env.OTEL_RESOURCE_ATTRIBUTES).toBe("service.name=opencode-test") - expect(WorkspaceOld.status().find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") + expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") - await WorkspaceOld.remove(workspaceID) - expect(WorkspaceOld.status().find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined() + await removeWorkspace(workspaceID) + expect((await workspaceStatus()).find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined() }) }) @@ -485,9 +503,9 @@ describe("workspace-old CRUD", () => { ) await expect( - WorkspaceOld.create({ type, branch: null, projectID: Instance.project.id, extra: null }), + createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }), ).rejects.toThrow("configure exploded") - expect(WorkspaceOld.list(Instance.project)).toEqual([]) + expect(await listWorkspaces(Instance.project)).toEqual([]) }) }) @@ -505,14 +523,14 @@ describe("workspace-old CRUD", () => { registerAdaptor(Instance.project.id, type, recorded.adaptor) await expect( - WorkspaceOld.create({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), + createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), ).rejects.toThrow("create exploded") - const rows = WorkspaceOld.list(Instance.project) + const rows = await listWorkspaces(Instance.project) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } }) expect(recorded.calls.target).toHaveLength(0) - await WorkspaceOld.remove(rows[0].id) + await removeWorkspace(rows[0].id) }) }) @@ -523,11 +541,11 @@ describe("workspace-old CRUD", () => { const recorded = localAdaptor(missing, { createDir: false }) registerAdaptor(Instance.project.id, type, recorded.adaptor) - const info = await WorkspaceOld.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) expect(info.directory).toBe(missing) - expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error") - await WorkspaceOld.remove(info.id) + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + await removeWorkspace(info.id) }) }) @@ -581,7 +599,7 @@ describe("workspace-old CRUD", () => { test("remove returns undefined for a missing workspace", async () => { await withInstance(async () => { - expect(await WorkspaceOld.remove(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined() + expect(await removeWorkspace(WorkspaceID.ascending("wrk_missing_remove"))).toBeUndefined() }) }) @@ -590,18 +608,18 @@ describe("workspace-old CRUD", () => { const type = unique("remove-local") const recorded = localAdaptor(path.join(dir, "remove-local")) registerAdaptor(Instance.project.id, type, recorded.adaptor) - const info = await WorkspaceOld.create({ type, branch: null, projectID: Instance.project.id, extra: null }) + 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({}))) attachSessionToWorkspace(one.id, info.id) attachSessionToWorkspace(two.id, info.id) - const removed = await WorkspaceOld.remove(info.id) + const removed = await removeWorkspace(info.id) expect(removed).toEqual(info) - expect(await WorkspaceOld.get(info.id)).toBeUndefined() + expect(await getWorkspace(info.id)).toBeUndefined() expect(recorded.calls.remove).toEqual([info]) - expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() expect( Database.use((db) => db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(), @@ -628,8 +646,8 @@ describe("workspace-old CRUD", () => { ) insertWorkspace(info) - expect(await WorkspaceOld.remove(info.id)).toEqual(info) - expect(await WorkspaceOld.get(info.id)).toBeUndefined() + expect(await removeWorkspace(info.id)).toEqual(info) + expect(await getWorkspace(info.id)).toBeUndefined() }) }) }) @@ -645,10 +663,10 @@ describe("workspace-old sync state", () => { insertWorkspace(info) registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor) - WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) await delay(25) - expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBeUndefined() + expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() }) }) @@ -671,14 +689,16 @@ describe("workspace-old sync state", () => { withSession.id, ) - WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) await eventually(() => - expect(WorkspaceOld.status().find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), + ), ) - expect(WorkspaceOld.status().find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() - await WorkspaceOld.remove(withSession.id) - await WorkspaceOld.remove(withoutSession.id) + expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() + await removeWorkspace(withSession.id) + await removeWorkspace(withoutSession.id) }) }) @@ -697,13 +717,15 @@ describe("workspace-old sync state", () => { info.id, ) - WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) await eventually(() => - expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("error"), + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("error"), + ), ) - expect(await WorkspaceOld.isSyncing(info.id)).toBe(false) - await WorkspaceOld.remove(info.id) + expect(await isWorkspaceSyncing(info.id)).toBe(false) + await removeWorkspace(info.id) }) }) @@ -722,18 +744,20 @@ describe("workspace-old sync state", () => { info.id, ) - WorkspaceOld.startWorkspaceSyncing(Instance.project.id) - WorkspaceOld.startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) + startWorkspaceSyncing(Instance.project.id) await eventually(() => - expect(WorkspaceOld.status().find((item) => item.workspaceID === info.id)?.status).toBe("connected"), + workspaceStatus().then((status) => + expect(status.find((item) => item.workspaceID === info.id)?.status).toBe("connected"), + ), ) expect( captured.events.filter( (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, ), ).toHaveLength(1) - await WorkspaceOld.remove(info.id) + await removeWorkspace(info.id) } finally { captured.dispose() } @@ -1106,7 +1130,7 @@ describe("workspace-old sync state", () => { describe("workspace-old waitForSync", () => { test("returns immediately for an empty fence", async () => { await withInstance(async () => { - await expect(WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() + await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() }) }) @@ -1116,10 +1140,10 @@ describe("workspace-old waitForSync", () => { Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run()) await expect( - WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }), + waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done"), { [sessionID]: 4 }), ).resolves.toBeUndefined() await expect( - WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), + waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), ).resolves.toBeUndefined() }) }) @@ -1130,7 +1154,7 @@ describe("workspace-old waitForSync", () => { const sessionID = SessionID.descending("ses_wait_event") Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 1 }).run()) - const waited = WorkspaceOld.waitForSync(workspaceID, { [sessionID]: 2 }) + const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 2 }) await delay(10) Database.use((db) => db.update(EventSequenceTable).set({ seq: 2 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), @@ -1147,7 +1171,7 @@ describe("workspace-old waitForSync", () => { const sessionID = SessionID.descending("ses_wait_sync_any") Database.use((db) => db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 0 }).run()) - const waited = WorkspaceOld.waitForSync(workspaceID, { [sessionID]: 1 }) + const waited = waitForWorkspaceSync(workspaceID, { [sessionID]: 1 }) await delay(10) Database.use((db) => db.update(EventSequenceTable).set({ seq: 1 }).where(eq(EventSequenceTable.aggregate_id, sessionID)).run(), @@ -1165,7 +1189,7 @@ describe("workspace-old waitForSync", () => { await withInstance(async () => { const abort = new AbortController() const reason = new Error("caller aborted") - const waited = WorkspaceOld.waitForSync( + const waited = waitForWorkspaceSync( WorkspaceID.ascending("wrk_wait_abort"), { [SessionID.descending("ses_wait_abort")]: 1 }, abort.signal, @@ -1184,9 +1208,9 @@ describe("workspace-old waitForSync", () => { await withInstance(async () => { const sessionID = SessionID.descending("ses_wait_timeout") - await expect( - WorkspaceOld.waitForSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 }), - ).rejects.toThrow(`Timed out waiting for sync fence: {"${sessionID}":1}`) + await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_timeout"), { [sessionID]: 1 })).rejects.toThrow( + `Timed out waiting for sync fence: {"${sessionID}":1}`, + ) }) }, 7000) }) @@ -1195,7 +1219,7 @@ describe("workspace-old sessionRestore", () => { test("throws when the workspace is missing", async () => { await withInstance(async () => { await expect( - WorkspaceOld.sessionRestore({ + restoreWorkspaceSession({ workspaceID: WorkspaceID.ascending("wrk_restore_missing"), sessionID: SessionID.descending("ses_restore_missing_workspace"), }), @@ -1211,9 +1235,9 @@ describe("workspace-old sessionRestore", () => { registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) await expect( - WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), + restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), ).rejects.toThrow("NotFoundError") - await WorkspaceOld.remove(info.id) + await removeWorkspace(info.id) }) }) @@ -1424,7 +1448,7 @@ describe("workspace-old sessionRestore", () => { ) replaceSessionEvents(session.id, 20) - expect(await WorkspaceOld.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) + expect(await restoreWorkspaceSession({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) expect(fetchCallCount).toBe(0) expect(replayAll).toHaveBeenCalledTimes(3) @@ -1438,7 +1462,7 @@ describe("workspace-old sessionRestore", () => { .filter((event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type) .map((event) => event.payload.properties.step), ).toEqual([0, 1, 2, 3]) - await WorkspaceOld.remove(info.id) + await removeWorkspace(info.id) } finally { captured.dispose() } diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index c5b878c69bb6..677c004be47e 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -13,7 +13,7 @@ const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") const { Instance } = await import("../../src/project/instance") -const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, Workspace.defaultLayer, CrossSpawnSpawner.defaultLayer)) const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES @@ -83,14 +83,13 @@ describe("plugin.workspace", () => { const plugin = yield* Plugin.Service yield* plugin.init() - const info = yield* Effect.promise(() => - Workspace.create({ - type, - branch: null, - extra: { key: "value" }, - projectID: Instance.project.id, - }), - ) + const workspace = yield* Workspace.Service + const info = yield* workspace.create({ + type, + branch: null, + extra: { key: "value" }, + projectID: Instance.project.id, + }) expect(info.type).toBe(type) expect(info.name).toBe("plug") diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 0817b9003604..28945f02133a 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -36,7 +36,13 @@ const testStateLayer = Layer.effectDiscard( ) const it = testEffect( - Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer), + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + Project.defaultLayer, + Workspace.defaultLayer, + ), ) const instanceContextTestLayer = instanceRouterMiddleware @@ -56,16 +62,17 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.acquireRelease( - Effect.promise(async () => { + Effect.gen(function* () { registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) - return Workspace.create({ + const workspace = yield* Workspace.Service + return yield* workspace.create({ type: input.type, branch: null, extra: null, projectID: input.projectID, }) }), - (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), + (info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore), ) const probeInstanceContext = Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c7d09454367f..11e9d8b1858a 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -94,14 +94,16 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ }) const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => - Effect.promise(async () => { + Effect.gen(function* () { registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) - return Workspace.create({ - type: input.type, - branch: null, - extra: null, - projectID: input.projectID, - }) + return yield* Workspace.Service.use((svc) => + svc.create({ + type: input.type, + branch: null, + extra: null, + projectID: input.projectID, + }), + ).pipe(Effect.provide(Workspace.defaultLayer)) }) function request(path: string, init?: RequestInit) { diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index b52b95d86c5c..57312678f66d 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -50,6 +50,7 @@ const it = testEffect( NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer, + Workspace.defaultLayer, Socket.layerWebSocketConstructorGlobal, ), ) @@ -116,16 +117,17 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => Effect.acquireRelease( - Effect.promise(async () => { + Effect.gen(function* () { registerAdaptor(input.projectID, input.type, input.adaptor) - return Workspace.create({ + const workspace = yield* Workspace.Service + return yield* workspace.create({ type: input.type, branch: null, extra: null, projectID: input.projectID, }) }), - (workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore), + (info) => Workspace.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore), ) const createRemoteWorkspace = (input: { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 74dfbaef8609..96b57e0dfea4 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, mock, test } from "bun:test" +import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Effect } from "effect" +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" @@ -11,30 +12,28 @@ 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 { tmpdir } from "../fixture/fixture" +import { 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" import { WorkspaceRef } from "../../src/effect/instance-ref" +import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const it = testEffect( + Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), +) function request(path: string, directory: string, init: RequestInit = {}) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Server.Default().app.request(path, { ...init, headers }) -} - -function runSession(fx: Effect.Effect, workspaceID?: Workspace.Info["id"]) { - return Effect.runPromise( - fx.pipe( - workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect, - Effect.provide(Session.defaultLayer), - ), - ) + return Effect.promise(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) + }) } function localAdaptor(directory: string): WorkspaceAdaptor { @@ -136,243 +135,228 @@ afterEach(async () => { describe("workspace HttpApi", () => { test.todo("proxies remote workspace websocket through real Effect listener", () => {}) - test("serves read endpoints", async () => { - await using tmp = await tmpdir({ git: true }) + it.live("serves read endpoints", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) - const [adaptors, workspaces, status] = await Promise.all([ - request(WorkspacePaths.adaptors, tmp.path), - request(WorkspacePaths.list, tmp.path), - request(WorkspacePaths.status, tmp.path), - ]) + const [adaptors, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adaptors, dir), + request(WorkspacePaths.list, dir), + request(WorkspacePaths.status, dir), + ]) - expect(adaptors.status).toBe(200) - expect(await adaptors.json()).toEqual([ - { + expect(adaptors.status).toBe(200) + expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", - }, - ]) + }) - expect(workspaces.status).toBe(200) - expect(await workspaces.json()).toEqual([]) + expect(workspaces.status).toBe(200) + expect(yield* Effect.promise(() => workspaces.json())).toEqual([]) - expect(status.status).toBe(200) - expect(await status.json()).toEqual([]) - }) + expect(status.status).toBe(200) + expect(yield* Effect.promise(() => status.json())).toEqual([]) + }), + ) - test("serves mutation endpoints", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => - registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))), - }) - - const created = await request(WorkspacePaths.list, tmp.path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), - }) - expect(created.status).toBe(200) - const workspace = (await created.json()) as Workspace.Info - expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) - - const session = await Instance.provide({ - directory: tmp.path, - fn: async () => runSession(Session.Service.use((svc) => svc.create({}))), - }) - const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) }) - - const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" }) - expect(removed.status).toBe(200) - expect(await removed.json()).toMatchObject({ id: workspace.id }) - - const listed = await request(WorkspacePaths.list, tmp.path) - expect(listed.status).toBe(200) - expect(await listed.json()).toEqual([]) - }) + it.live("serves mutation endpoints", () => + Effect.gen(function* () { + 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"))) - test("routes local workspace requests through the workspace target directory", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const workspaceDir = path.join(tmp.path, ".workspace-local") - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir)) - return Workspace.create({ - type: "local-target", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }, - }) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null, extra: 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 url = new URL(`http://localhost${InstancePaths.path}`) - url.searchParams.set("workspace", workspace.id) + const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) + const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), 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), + }) + + const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id }) + + const listed = yield* request(WorkspacePaths.list, dir) + expect(listed.status).toBe(200) + expect(yield* Effect.promise(() => listed.json())).toEqual([]) + }), + ) + + it.live("routes local workspace requests through the workspace target directory", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + 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)) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info - try { - const response = await request(url.toString(), tmp.path) + const url = new URL(`http://localhost${InstancePaths.path}`) + url.searchParams.set("workspace", workspace.id) + + const response = yield* request(url.toString(), dir) expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ directory: workspaceDir }) - } finally { - await Workspace.remove(workspace.id) - } - }) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir }) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + }), + ) - test("proxies remote workspace HTTP requests with sanitized forwarding", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const proxied: ProxiedRequest[] = [] - const remote = listenRemoteHttp((request) => { - proxied.push(request) - const url = new URL(request.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - return new Response( - JSON.stringify({ - proxied: true, - path: url.pathname, - keep: url.searchParams.get("keep"), - workspace: url.searchParams.get("workspace"), - }), - { - status: 201, - statusText: "Created", - headers: { - "content-length": "999", - "content-type": "application/json", - "x-remote": "yes", - }, - }, - ) - }) - - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor( - Instance.project.id, - "remote-target", - remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, { - "x-target-auth": "secret", + it.live("proxies remote workspace HTTP requests with sanitized forwarding", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return new Response( + JSON.stringify({ + proxied: true, + path: url.pathname, + keep: url.searchParams.get("keep"), + workspace: url.searchParams.get("workspace"), }), + { + status: 201, + statusText: "Created", + headers: { + "content-length": "999", + "content-type": "application/json", + "x-remote": "yes", + }, + }, ) - return Workspace.create({ - type: "remote-target", - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }, - }) - - const url = new URL("http://localhost/config") - url.searchParams.set("workspace", workspace.id) - url.searchParams.set("keep", "yes") - - try { - const response = await request(url.toString(), tmp.path, { - method: "PATCH", - headers: { - "accept-encoding": "br", - "content-type": "application/json", - "x-opencode-workspace": "internal", - }, - body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), }) - const responseBody = await response.text() - expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) - expect(response.headers.get("content-length")).toBeNull() - expect(response.headers.get("x-remote")).toBe("yes") - expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) - const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") - expect(forwarded).toEqual([ - { - url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + const project = yield* Project.use.fromDirectory(dir) + registerAdaptor( + project.project.id, + "remote-target", + remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + "x-target-auth": "secret", + }), + ) + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + + const url = new URL("http://localhost/config") + url.searchParams.set("workspace", workspace.id) + url.searchParams.set("keep", "yes") + + try { + const response = yield* request(url.toString(), dir, { method: "PATCH", - headers: expect.objectContaining({ + headers: { + "accept-encoding": "br", "content-type": "application/json", - "x-target-auth": "secret", - }), + "x-opencode-workspace": "internal", + }, body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), - }, - ]) - expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") - expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") - } finally { - remote.stop(true) - await Workspace.remove(workspace.id) - } - }) - - test("proxies remote workspace requests selected from session ownership", async () => { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - await using tmp = await tmpdir({ git: true }) - const proxied: ProxiedRequest[] = [] - const remote = listenRemoteHttp((request) => { - proxied.push(request) - const url = new URL(request.url) - if (url.pathname === "/base/global/event") return eventStreamResponse() - if (url.pathname === "/base/sync/history") return Response.json([]) - return Response.json({ proxied: true, path: new URL(request.url).pathname }) - }) - - const workspace = await Instance.provide({ - directory: tmp.path, - fn: async () => { - registerAdaptor( - Instance.project.id, - "remote-session-target", - remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), - ) - return Workspace.create({ - type: "remote-session-target", - branch: null, - extra: null, - projectID: Instance.project.id, }) - }, - }) - const session = await Instance.provide({ - directory: tmp.path, - fn: async () => - runSession( - Session.Service.use((svc) => svc.create()), - workspace.id, - ), - }) - - try { - const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, { + + const responseBody = yield* Effect.promise(() => response.text()) + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 }) + expect(response.headers.get("content-length")).toBeNull() + expect(response.headers.get("x-remote")).toBe("yes") + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null }) + const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config") + expect(forwarded).toEqual([ + { + url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`, + method: "PATCH", + headers: expect.objectContaining({ + "content-type": "application/json", + "x-target-auth": "secret", + }), + body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }), + }, + ]) + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory") + expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") + } finally { + void remote.stop(true) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) + + it.live("proxies remote workspace requests selected from session ownership", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const proxied: ProxiedRequest[] = [] + const remote = listenRemoteHttp((request) => { + proxied.push(request) + const url = new URL(request.url) + if (url.pathname === "/base/global/event") return eventStreamResponse() + if (url.pathname === "/base/sync/history") return Response.json([]) + return Response.json({ proxied: true, path: new URL(request.url).pathname }) + }) + + const project = yield* Project.use.fromDirectory(dir) + registerAdaptor( + project.project.id, + "remote-session-target", + remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + ) + const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), }) + const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const session = yield* Session.Service.use((svc) => svc.create()).pipe( + Effect.provideService(WorkspaceRef, workspace.id), + provideInstance(dir), + ) - const responseBody = await response.text() - expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) - expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) - expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ - expect.objectContaining({ - url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + try { + const response = yield* request(`http://localhost/session/${session.id}/message`, dir, { method: "POST", - }), - ]) - } finally { - remote.stop(true) - await Workspace.remove(workspace.id) - } - }) + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), + }) + + const responseBody = yield* Effect.promise(() => response.text()) + expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 }) + expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` }) + expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([ + expect.objectContaining({ + url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`, + method: "POST", + }), + ]) + } finally { + void remote.stop(true) + yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + } + }), + ) }) From 3250b814ce8a3523898d52fa68ee0e5e7ddb129f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 15:55:20 -0400 Subject: [PATCH 0057/1114] Fix HttpApi raw route authorization (#25154) --- .../routes/instance/httpapi/handlers/sync.ts | 19 +++- .../httpapi/middleware/authorization.ts | 69 ++++++++++++-- .../server/routes/instance/httpapi/server.ts | 7 +- .../server/httpapi-raw-route-auth.test.ts | 89 +++++++++++++++++++ .../opencode/test/server/httpapi-sync.test.ts | 6 +- 5 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-raw-route-auth.test.ts 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 2ff4177f3119..fbe1249939d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -13,6 +13,9 @@ import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { HistoryPayload, ReplayPayload } from "../groups/sync" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.sync" }) export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => Effect.gen(function* () { @@ -34,8 +37,22 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl type: event.type, data: { ...event.data }, })) + const source = events[0].aggregateID + log.info("sync replay requested", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + directory: ctx.payload.directory, + }) SyncEvent.replayAll(events) - return { sessionID: events[0].aggregateID } + log.info("sync replay complete", { + sessionID: source, + events: events.length, + first: events[0]?.seq, + last: events.at(-1)?.seq, + }) + return { sessionID: source } }) const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { 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 b246140a0066..e022a568ac07 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,14 +1,18 @@ import { ConfigService } from "@/effect/config-service" import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +const AUTH_TOKEN_QUERY = "auth_token" +const UNAUTHORIZED = 401 + export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, security: { basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }), + authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), }, }, ) {} @@ -27,18 +31,27 @@ function validateCredential( config: Context.Service.Shape, ) { return Effect.gen(function* () { - if (Option.isNone(config.password) || config.password.value === "") return yield* effect - - if (credential.username !== config.username) { - return yield* new HttpApiError.Unauthorized({}) - } - if (Redacted.value(credential.password) !== config.password.value) { - return yield* new HttpApiError.Unauthorized({}) - } + if (!isAuthRequired(config)) return yield* effect + if (!isCredentialAuthorized(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: "", @@ -62,6 +75,44 @@ function decodeCredential(input: string) { ) } +function validateRawCredential( + effect: Effect.Effect, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, +) { + if (!isAuthRequired(config)) return effect + if (!isCredentialAuthorized(credential, config)) + return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + return effect +} + +export const authorizationRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const config = yield* ServerAuthConfig + if (!isAuthRequired(config)) return (effect) => effect + + 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) + }) + }), +) + export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index caca845be394..62fa18743a83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -38,7 +38,7 @@ import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" +import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -104,9 +104,10 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( Layer.provide( - instanceRouterMiddleware + authorizationRouterMiddleware + .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)), ), ) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts new file mode 100644 index 000000000000..af373d933ba1 --- /dev/null +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Instance } from "../../src/project/instance" +import { EventPaths } from "../../src/server/routes/instance/httpapi/event" +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 * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +function app(input: { password?: string; username?: string }) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input.password, + OPENCODE_SERVER_USERNAME: input.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } +} + +function basic(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +async function cancelBody(response: Response) { + await response.body?.cancel().catch(() => {}) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + await Instance.disposeAll() + await resetDatabase() +}) + +describe("HttpApi raw route authorization", () => { + test("requires configured auth before opening the raw instance event stream", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const headers = { "x-opencode-directory": tmp.path } + + const missing = await server.request(EventPaths.event, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(EventPaths.event, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(200) + }) + + test("requires configured auth before resolving the raw PTY websocket route", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const server = app({ password: "secret" }) + const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending()) + const headers = { "x-opencode-directory": tmp.path } + + const missing = await server.request(route, { headers }) + await cancelBody(missing) + expect(missing.status).toBe(401) + + const authed = await server.request(route, { + headers: { ...headers, authorization: basic("opencode", "secret") }, + }) + await cancelBody(authed) + expect(authed.status).toBe(404) + }) +}) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 5fa6784a1389..f51a7145750a 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test" +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" @@ -24,6 +24,7 @@ function runSession(fx: Effect.Effect) { } afterEach(async () => { + mock.restore() Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() @@ -35,6 +36,7 @@ describe("sync HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) 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({ directory: tmp.path, @@ -78,6 +80,8 @@ describe("sync HttpApi", () => { }) expect(replayed.status).toBe(200) expect(await replayed.json()).toEqual({ sessionID: session.id }) + expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) + expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) test("matches legacy seq validation", async () => { From fbcbd24063886a175ea41be1e0b7155cdb544a7b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 16:45:26 -0400 Subject: [PATCH 0058/1114] Add SyncEvent service (#25158) --- .../opencode/src/control-plane/workspace.ts | 91 +++-- packages/opencode/src/effect/app-runtime.ts | 2 + .../routes/instance/httpapi/handlers/sync.ts | 3 +- .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/sync.ts | 2 +- packages/opencode/src/session/revert.ts | 6 +- packages/opencode/src/session/session.ts | 56 ++- packages/opencode/src/share/session.ts | 8 +- packages/opencode/src/sync/index.ts | 204 ++++++----- .../test/control-plane/workspace.test.ts | 77 ++--- packages/opencode/test/sync/index.test.ts | 323 +++++++++--------- 11 files changed, 427 insertions(+), 347 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index fe8046ba9cad..7f9d078bb7c4 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -169,6 +169,7 @@ export const layer = Layer.effect( const auth = yield* Auth.Service const session = yield* Session.Service const http = yield* HttpClient.HttpClient + const sync = yield* SyncEvent.Service const connections = new Map() const syncFibers = yield* FiberMap.make() @@ -307,25 +308,30 @@ export const layer = Layer.effect( events: events.length, }) - yield* Effect.sync(() => - WorkspaceContext.provide({ + yield* Effect.promise(async () => { + await WorkspaceContext.provide({ workspaceID: space.id, - fn: () => { - for (const event of events) { - SyncEvent.replay( - { - id: event.id, - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }, - { publish: true }, - ) - } + async fn() { + await Effect.runPromise( + Effect.forEach( + events, + (event) => + sync.replay( + { + id: event.id, + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }, + { publish: true }, + ), + { discard: true }, + ), + ) }, - }), - ) + }) + }) }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { @@ -361,16 +367,28 @@ export const layer = Layer.effect( setStatus(space.id, "connected") yield* parseSSE(stream, (evt) => - Effect.sync(() => { - try { - if (!evt || typeof evt !== "object" || !("payload" in evt)) return - const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } - if (payload.type === "server.heartbeat") return - - if (payload.type === "sync" && payload.syncEvent) { - SyncEvent.replay(payload.syncEvent) - } + Effect.gen(function* () { + if (!evt || typeof evt !== "object" || !("payload" in evt)) return + const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent } + if (payload.type === "server.heartbeat") return + + if (payload.type === "sync" && payload.syncEvent) { + const failed = yield* sync.replay(payload.syncEvent).pipe( + Effect.as(false), + Effect.catchCause((error) => + Effect.sync(() => { + log.info("failed to replay global event", { + workspaceID: space.id, + error, + }) + return true + }), + ), + ) + if (failed) return + } + try { const event = evt as { directory?: string; project?: string; payload: unknown } GlobalBus.emit("event", { directory: event.directory, @@ -378,10 +396,10 @@ export const layer = Layer.effect( workspace: space.id, payload: event.payload, }) - } catch (err) { + } catch (error) { log.info("failed to replay global event", { workspaceID: space.id, - error: err, + error, }) } }), @@ -516,14 +534,12 @@ export const layer = Layer.effect( const adaptor = getAdaptor(space.projectID, space.type) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: input.workspaceID, - }, - }), - ) + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) const rows = yield* db((db) => db @@ -593,7 +609,7 @@ export const layer = Layer.effect( }) if (target.type === "local") { - SyncEvent.replayAll(events) + yield* sync.replayAll(events) log.info("session restore batch replayed locally", { workspaceID: input.workspaceID, sessionID: input.sessionID, @@ -812,6 +828,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 84be1706888a..06969ff9d185 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -47,6 +47,7 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" +import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -97,6 +98,7 @@ export const AppLayer = Layer.mergeAll( Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) 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 fbe1249939d7..f4a2f315cd90 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -21,6 +21,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl Effect.gen(function* () { const workspace = yield* Workspace.Service const scope = yield* Scope.Scope + const sync = yield* SyncEvent.Service const start = Effect.fn("SyncHttpApi.start")(function* () { yield* workspace @@ -45,7 +46,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl last: events.at(-1)?.seq, directory: ctx.payload.directory, }) - SyncEvent.replayAll(events) + yield* sync.replayAll(events) log.info("sync replay complete", { sessionID: source, events: events.length, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 62fa18743a83..f53ddb3ec5ac 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -31,6 +31,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" import { Skill } from "@/skill" +import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" @@ -147,6 +148,7 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, ToolRegistry.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index bb816ecc4225..b7bf413d4ed1 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -94,7 +94,7 @@ export const SyncRoutes = lazy(() => last: events.at(-1)?.seq, directory: body.directory, }) - SyncEvent.replayAll(events) + await AppRuntime.runPromise(SyncEvent.use.replayAll(events)) log.info("sync replay complete", { sessionID: source, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index da9952ccb245..58d69a2040e2 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -38,6 +38,7 @@ export const layer = Layer.effect( const bus = yield* Bus.Service const summary = yield* SessionSummary.Service const state = yield* SessionRunState.Service + const sync = yield* SyncEvent.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { yield* state.assertNotBusy(input.sessionID) @@ -121,7 +122,7 @@ export const layer = Layer.effect( remove.push(msg) } for (const msg of remove) { - SyncEvent.run(MessageV2.Event.Removed, { + yield* sync.run(MessageV2.Event.Removed, { sessionID, messageID: msg.info.id, }) @@ -133,7 +134,7 @@ export const layer = Layer.effect( const removeParts = target.parts.slice(idx) target.parts = target.parts.slice(0, idx) for (const part of removeParts) { - SyncEvent.run(MessageV2.Event.PartRemoved, { + yield* sync.run(MessageV2.Event.PartRemoved, { sessionID, messageID: target.info.id, partID: part.id, @@ -156,6 +157,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Storage.defaultLayer), Layer.provide(Bus.layer), Layer.provide(SessionSummary.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 72c4d241eb13..5534976e399c 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -443,11 +443,12 @@ export type Patch = Types.DeepMutable["dat const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const storage = yield* Storage.Service + const sync = yield* SyncEvent.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -477,7 +478,7 @@ export const layer: Layer.Layer = } log.info("created", result) - yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) + yield* sync.run(Event.Created, { sessionID: result.id, info: result }) if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be @@ -525,10 +526,8 @@ export const layer: Layer.Layer = Effect.catchCause(() => Effect.succeed(false)), ) - yield* Effect.sync(() => { - SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - SyncEvent.remove(sessionID) - }) + yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + yield* sync.remove(sessionID) } catch (e) { log.error(e) } @@ -536,19 +535,17 @@ export const layer: Layer.Layer = const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { - yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })) + yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }), - ) + yield* sync.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }) return part }).pipe(Effect.withSpan("Session.updatePart")) @@ -635,8 +632,7 @@ export const layer: Layer.Layer = return session }) - const patch = (sessionID: SessionID, info: Patch) => - Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info })) + const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { yield* patch(sessionID, { time: { updated: Date.now() } }) @@ -693,12 +689,10 @@ export const layer: Layer.Layer = sessionID: SessionID messageID: MessageID }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }), - ) + yield* sync.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, + }) return input.messageID }) @@ -707,13 +701,11 @@ export const layer: Layer.Layer = messageID: MessageID partID: PartID }) { - yield* Effect.sync(() => - SyncEvent.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }), - ) + yield* sync.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) return input.partID }) @@ -764,7 +756,11 @@ export const layer: Layer.Layer = }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), +) export function* list(input?: { directory?: string diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 99e46a009251..7e4de204edb7 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -21,20 +21,19 @@ export const layer = Layer.effect( const session = yield* Session.Service const shareNext = yield* ShareNext.Service const scope = yield* Scope.Scope + const sync = yield* SyncEvent.Service const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { const conf = yield* cfg.get() if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") const result = yield* shareNext.create(sessionID) - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), - ) + yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }) return result }) const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { yield* shareNext.remove(sessionID) - yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }) }) const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) { @@ -54,6 +53,7 @@ export const defaultLayer = layer.pipe( Layer.provide(ShareNext.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ) export * as SessionShare from "./session" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 67bc9b9e7cc3..ebf7543af103 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,9 +9,11 @@ import { EventSequenceTable, EventTable } from "./event.sql" import { WorkspaceContext } from "@/control-plane/workspace-context" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" -import { Schema as EffectSchema } from "effect" +import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import { zodObject } from "@/util/effect-zod" import type { DeepMutable } from "@/util/schema" +import { makeRuntime } from "@/effect/run-service" +import { serviceUse } from "@/effect/service-use" // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -46,6 +48,125 @@ export type SerializedEvent = Event & type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise +export interface Interface { + readonly run: ( + def: Def, + 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 remove: (aggregateID: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SyncEvent") {} + +export const layer = Layer.effect(Service)( + Effect.gen(function* () { + const replay: Interface["replay"] = Effect.fn("SyncEvent.replay")(function* (event, options) { + const def = registry.get(event.type) + if (!def) { + throw new Error(`Unknown event type: ${event.type}`) + } + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) + .get(), + ) + + const latest = row?.seq ?? -1 + if (event.seq <= latest) return + + const expected = latest + 1 + if (event.seq !== expected) { + throw new Error( + `Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`, + ) + } + + process(def, event, { publish: !!options?.publish }) + }) + + const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { + const source = events[0]?.aggregateID + if (!source) return undefined + if (events.some((item) => item.aggregateID !== source)) { + throw new Error("Replay events must belong to the same session") + } + const start = events[0].seq + for (const [i, item] of events.entries()) { + const seq = start + i + if (item.seq !== seq) { + throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) + } + } + for (const item of events) { + yield* replay(item, options) + } + return source + }) + + const run: Interface["run"] = Effect.fn("SyncEvent.run")(function* (def, data, options) { + const agg = (data as Record)[def.aggregate] + // This should never happen: we've enforced it via typescript in + // the definition + if (agg == null) { + throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) + } + + if (def.version !== versions.get(def.type)) { + throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) + } + + const { publish = true } = options || {} + + // Note that this is an "immediate" transaction which is critical. + // We need to make sure we can safely read and write with nothing + // else changing the data from under us + Database.transaction( + (tx) => { + const id = EventID.ascending() + const row = tx + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, agg)) + .get() + const seq = row?.seq != null ? row.seq + 1 : 0 + + const event = { id, seq, aggregateID: agg, data } + process(def, event, { publish }) + }, + { + behavior: "immediate", + }, + ) + }) + + const remove: Interface["remove"] = Effect.fn("SyncEvent.remove")(function* (aggregateID) { + Database.transaction((tx) => { + tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() + }) + }) + + return Service.of({ + run, + replay, + replayAll, + remove, + }) + }), +) + +export const defaultLayer = layer + +export const use = serviceUse(Service) + +const runtime = makeRuntime(Service, defaultLayer) + export const registry = new Map() let projectors: Map | undefined const versions = new Map() @@ -186,92 +307,19 @@ function process(def: Def, event: Event, options: { } export function replay(event: SerializedEvent, options?: { publish: boolean }) { - const def = registry.get(event.type) - if (!def) { - throw new Error(`Unknown event type: ${event.type}`) - } - - const row = Database.use((db) => - db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) - .get(), - ) - - const latest = row?.seq ?? -1 - if (event.seq <= latest) { - return - } - - const expected = latest + 1 - if (event.seq !== expected) { - throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`) - } - - process(def, event, { publish: !!options?.publish }) + return runtime.runSync((sync) => sync.replay(event, options)) } export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { - const source = events[0]?.aggregateID - if (!source) return - if (events.some((item) => item.aggregateID !== source)) { - throw new Error("Replay events must belong to the same session") - } - const start = events[0].seq - for (const [i, item] of events.entries()) { - const seq = start + i - if (item.seq !== seq) { - throw new Error(`Replay sequence mismatch at index ${i}: expected ${seq}, got ${item.seq}`) - } - } - for (const item of events) { - replay(item, options) - } - return source + return runtime.runSync((sync) => sync.replayAll(events, options)) } export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { - const agg = (data as Record)[def.aggregate] - // This should never happen: we've enforced it via typescript in - // the definition - if (agg == null) { - throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`) - } - - if (def.version !== versions.get(def.type)) { - throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`) - } - - const { publish = true } = options || {} - - // Note that this is an "immediate" transaction which is critical. - // We need to make sure we can safely read and write with nothing - // else changing the data from under us - Database.transaction( - (tx) => { - const id = EventID.ascending() - const row = tx - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, agg)) - .get() - const seq = row?.seq != null ? row.seq + 1 : 0 - - const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish }) - }, - { - behavior: "immediate", - }, - ) + return runtime.runSync((sync) => sync.run(def, data, options)) } export function remove(aggregateID: string) { - Database.transaction((tx) => { - tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() - tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() - }) + return runtime.runSync((sync) => sync.remove(aggregateID)) } export function payloads() { diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 6e68730a9061..594789b2075b 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import fs from "node:fs/promises" import Http from "node:http" import path from "node:path" @@ -1426,48 +1426,41 @@ describe("workspace-old sessionRestore", () => { }) }) - test("local restore replays batches without fetch and emits progress", async () => { - await withInstance(async (dir) => { - const captured = captureGlobalEvents() - let fetchCallCount = 0 - const replayAll = spyOn(SyncEvent, "replayAll") - try { - using server = Bun.serve({ - port: 0, - fetch() { - fetchCallCount++ - return Response.json({ ok: true }) - }, - }) - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) - const session = await AppRuntime.runPromise( - SessionNs.Service.use((svc) => svc.create({ title: "restore local" })), - ) - replaceSessionEvents(session.id, 20) - - expect(await restoreWorkspaceSession({ workspaceID: info.id, sessionID: session.id })).toEqual({ total: 3 }) + 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) + registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + const session = yield* sessionSvc.create({ title: "restore local" }) + replaceSessionEvents(session.id, 20) - expect(fetchCallCount).toBe(0) - expect(replayAll).toHaveBeenCalledTimes(3) - expect(replayAll.mock.calls.map((call) => call[0].length)).toEqual([10, 10, 1]) - expect((await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.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]) - await removeWorkspace(info.id) - } finally { - captured.dispose() - } - }) - }) + 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[] = [] diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 32a08715cad1..0afbb1831757 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -1,16 +1,18 @@ -import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test" -import { tmpdir } from "../fixture/fixture" -import { Schema } from "effect" +import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test" +import { provideTmpdirInstance } from "../fixture/fixture" +import { Effect, Layer, Schema } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" -import { Instance } from "../../src/project/instance" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" import { EventTable } from "../../src/sync/event.sql" -import { Identifier } from "../../src/id/id" +import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" +import { testEffect } from "../lib/effect" const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer)) beforeEach(() => { Database.close() @@ -22,19 +24,6 @@ afterEach(() => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original }) -function withInstance(fn: () => void | Promise) { - return async () => { - await using tmp = await tmpdir() - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await fn() - }, - }) - } -} - describe("SyncEvent", () => { function setup() { SyncEvent.reset() @@ -59,179 +48,209 @@ describe("SyncEvent", () => { return { Created, Sent } } + function expectDefect(effect: Effect.Effect, pattern: RegExp) { + return Effect.gen(function* () { + const exit = yield* Effect.exit(effect) + if (exit._tag === "Success") throw new Error("Expected effect to fail") + expect(String(exit.cause)).toMatch(pattern) + }) + } + afterAll(() => { SyncEvent.reset() initProjectors() }) describe("run", () => { - test( + it.live( "inserts event row", - withInstance(() => { - const { Created } = setup() - SyncEvent.run(Created, { id: "evt_1", name: "first" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].type).toBe("item.created.1") - expect(rows[0].aggregate_id).toBe("evt_1") - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].type).toBe("item.created.1") + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ), ) - test( + it.live( "increments seq per aggregate", - withInstance(() => { - const { Created } = setup() - SyncEvent.run(Created, { id: "evt_1", name: "first" }) - SyncEvent.run(Created, { id: "evt_1", name: "second" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(2) - expect(rows[1].seq).toBe(rows[0].seq + 1) - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" }) + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(2) + expect(rows[1].seq).toBe(rows[0].seq + 1) + }), + ), ) - test( + it.live( "uses custom aggregate field from agg()", - withInstance(() => { - const { Sent } = setup() - SyncEvent.run(Sent, { item_id: "evt_1", to: "james" }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe("evt_1") - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Sent } = setup() + yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe("evt_1") + }), + ), ) - test( + it.live( "emits events", - withInstance(async () => { - const { Created } = setup() - const events: Array<{ - type: string - properties: { id: string; name: string } - }> = [] - const received = new Promise((resolve) => { - Bus.subscribeAll((event) => { + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const events: Array<{ + type: string + properties: { id: string; name: string } + }> = [] + let resolve = () => {} + const received = new Promise((done) => { + resolve = done + }) + const dispose = Bus.subscribeAll((event) => { events.push(event) resolve() }) - }) - - SyncEvent.run(Created, { id: "evt_1", name: "test" }) - - await received - expect(events).toHaveLength(1) - expect(events[0]).toEqual({ - type: "item.created", - properties: { - id: "evt_1", - name: "test", - }, - }) - }), + try { + yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) + yield* Effect.promise(() => received) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "item.created", + properties: { + id: "evt_1", + name: "test", + }, + }) + } finally { + dispose() + } + }), + ), ) }) describe("replay", () => { - test( + it.live( "inserts event from external payload", - withInstance(() => { - const id = Identifier.descending("message") - SyncEvent.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "replayed" }, - }) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows).toHaveLength(1) - expect(rows[0].aggregate_id).toBe(id) - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* SyncEvent.use.replay({ + id: "evt_1", + type: "item.created.1", + seq: 0, + aggregateID: id, + data: { id, name: "replayed" }, + }) + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows).toHaveLength(1) + expect(rows[0].aggregate_id).toBe(id) + }), + ), ) - test( + it.live( "throws on sequence mismatch", - withInstance(() => { - const id = Identifier.descending("message") - SyncEvent.replay({ - id: "evt_1", - type: "item.created.1", - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }) - expect(() => - SyncEvent.replay({ + provideTmpdirInstance(() => + Effect.gen(function* () { + const id = MessageID.ascending() + yield* SyncEvent.use.replay({ id: "evt_1", type: "item.created.1", - seq: 5, + seq: 0, aggregateID: id, - data: { id, name: "bad" }, - }), - ).toThrow(/Sequence mismatch/) - }), + data: { id, name: "first" }, + }) + yield* expectDefect( + SyncEvent.use.replay({ + id: "evt_1", + type: "item.created.1", + seq: 5, + aggregateID: id, + data: { id, name: "bad" }, + }), + /Sequence mismatch/, + ) + }), + ), ) - test( + it.live( "throws on unknown event type", - withInstance(() => { - expect(() => - SyncEvent.replay({ - id: "evt_1", - type: "unknown.event.1", - seq: 0, - aggregateID: "x", - data: {}, - }), - ).toThrow(/Unknown event type/) - }), + provideTmpdirInstance(() => + Effect.gen(function* () { + yield* expectDefect( + SyncEvent.use.replay({ + id: "evt_1", + type: "unknown.event.1", + seq: 0, + aggregateID: "x", + data: {}, + }), + /Unknown event type/, + ) + }), + ), ) - test( + it.live( "replayAll accepts later chunks after the first batch", - withInstance(() => { - const { Created } = setup() - const id = Identifier.descending("message") + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() - const one = SyncEvent.replayAll([ - { - id: "evt_1", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 0, - aggregateID: id, - data: { id, name: "first" }, - }, - { - id: "evt_2", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 1, - aggregateID: id, - data: { id, name: "second" }, - }, - ]) - - const two = SyncEvent.replayAll([ - { - id: "evt_3", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 2, - aggregateID: id, - data: { id, name: "third" }, - }, - { - id: "evt_4", - type: SyncEvent.versionedType(Created.type, Created.version), - seq: 3, - aggregateID: id, - data: { id, name: "fourth" }, - }, - ]) + const one = yield* SyncEvent.use.replayAll([ + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "second" }, + }, + ]) + + const two = yield* SyncEvent.use.replayAll([ + { + id: "evt_3", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 2, + aggregateID: id, + data: { id, name: "third" }, + }, + { + id: "evt_4", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 3, + aggregateID: id, + data: { id, name: "fourth" }, + }, + ]) - expect(one).toBe(id) - expect(two).toBe(id) + expect(one).toBe(id) + expect(two).toBe(id) - const rows = Database.use((db) => db.select().from(EventTable).all()) - expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) - }), + const rows = Database.use((db) => db.select().from(EventTable).all()) + expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3]) + }), + ), ) }) }) From feb275d08b6340f50b44aed113340889817bc78d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 16:58:08 -0400 Subject: [PATCH 0059/1114] Remove covered workspace websocket todo (#25161) --- packages/opencode/test/server/httpapi-workspace.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 96b57e0dfea4..6a04833e35b4 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, test } from "bun:test" +import { afterEach, describe, expect, mock } from "bun:test" import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" import path from "node:path" @@ -133,8 +133,6 @@ afterEach(async () => { }) describe("workspace HttpApi", () => { - test.todo("proxies remote workspace websocket through real Effect listener", () => {}) - it.live("serves read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From b80f52f8ad3173acee143e1355a2ab4585443db1 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:03:07 -0500 Subject: [PATCH 0060/1114] tweak: adjust codex plugin to use the models hook (#25157) --- packages/opencode/src/plugin/codex.ts | 80 ++++++++++++++------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index a0ff0002f48e..c573aa8b6005 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,6 +14,13 @@ 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", +]) interface PkceCodes { verifier: string @@ -358,50 +365,45 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { return { + provider: { + id: "openai", + async models(provider, ctx) { + if (ctx.auth?.type !== "oauth") return provider.models + + return Object.fromEntries( + Object.entries(provider.models) + .filter(([, model]) => { + if (ALLOWED_MODELS.has(model.api.id)) return true + const match = model.api.id.match(/^gpt-(\d+\.\d+)/) + return match ? parseFloat(match[1]) > 5.4 : false + }) + .map(([modelID, model]) => [ + modelID, + { + ...model, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: model.id.includes("gpt-5.5") + ? { + context: 400_000, + input: 272_000, + output: 128_000, + } + : model.limit, + }, + ]), + ) + }, + }, auth: { provider: "openai", - async loader(getAuth, provider) { + async loader(getAuth) { const auth = await getAuth() if (auth.type !== "oauth") return {} - // Filter models to only allowed Codex models for OAuth - const allowedModels = new Set([ - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.4-mini", - ]) - for (const [modelId, model] of Object.entries(provider.models)) { - if (modelId.includes("codex")) continue - if (allowedModels.has(model.api.id)) continue - const match = model.api.id.match(/^gpt-(\d+\.\d+)/) - if (match && parseFloat(match[1]) > 5.4) continue - delete provider.models[modelId] - } - - // Zero out costs for Codex (included with ChatGPT subscription) - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - cache: { read: 0, write: 0 }, - } - - // gpt-5.5 models temporarily have restricted context window size for codex plans - if (model.id.includes("gpt-5.5")) { - model.limit = { - context: 400_000, - //@ts-expect-error incorrect type for v1 sdk but works - input: 272_000, - output: 128_000, - } - } - } - return { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { From 924ba97055adaa02b6684131ae537eddb2b7bfe5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 21:04:24 +0000 Subject: [PATCH 0061/1114] chore: generate --- packages/opencode/src/plugin/codex.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c573aa8b6005..a97f3e9e8d40 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,13 +14,7 @@ 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.4", "gpt-5.4-mini"]) interface PkceCodes { verifier: string From 5518ecaefe69d5c35d9b0cda227f2dba733dba03 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 17:43:18 -0400 Subject: [PATCH 0062/1114] Fix HttpApi web UI fallback (#25163) --- .../server/routes/instance/httpapi/server.ts | 19 +++++++- .../opencode/test/server/httpapi-ui.test.ts | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-ui.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f53ddb3ec5ac..f62636bca84a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" @@ -38,6 +38,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" +import { UIRoutes } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" @@ -119,7 +120,21 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe( +const uiRoutes = lazy(() => UIRoutes()) +const uiRoute = HttpRouter.add("*", "/*", (request) => + Effect.promise(async () => + uiRoutes().fetch( + request.source instanceof Request + ? request.source + : new Request(new URL(request.originalUrl, "http://localhost"), { + method: request.method, + headers: request.headers, + }), + ), + ).pipe(Effect.map(HttpServerResponse.fromWeb)), +) + +export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ cors, runtime, diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts new file mode 100644 index 000000000000..9ca8b49f1ba8 --- /dev/null +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -0,0 +1,45 @@ +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" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + fetch: globalThis.fetch, +} + +afterEach(() => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + globalThis.fetch = original.fetch +}) + +describe("HttpApi UI fallback", () => { + test("serves the web UI through the experimental backend", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + let proxiedUrl: string | undefined + globalThis.fetch = ((input: RequestInfo | URL) => { + proxiedUrl = String(input instanceof Request ? input.url : input) + return Promise.resolve(new Response("opencode", { headers: { "content-type": "text/html" } })) + }) as typeof fetch + + const response = await Server.Default().app.request("/") + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + expect(await response.text()).toBe("opencode") + expect(proxiedUrl).toBe("https://app.opencode.ai/") + }) + + test("keeps matched API routes ahead of the UI fallback", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + globalThis.fetch = (() => { + throw new Error("UI fallback should not handle matched API routes") + }) as unknown as typeof fetch + + const response = await Server.Default().app.request("/session/nope") + + expect(response.status).toBe(404) + }) +}) From 560baae15d806509f97f4b17a8e5811cf97face1 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:15:56 -0500 Subject: [PATCH 0063/1114] fix: ensure user config takes precendence over plugin hooks for model resolution (#25167) --- packages/opencode/src/provider/provider.ts | 54 +++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fc835cf5ee00..24b599db08fd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1140,6 +1140,33 @@ const layer: Layer.Layer< return true } + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = database[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } + // extend database from config for (const [providerID, provider] of configProviders) { const existing = database[providerID] @@ -1326,33 +1353,6 @@ const layer: Layer.Layer< }) } - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = providers[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelID.make(id), - providerID, - }, - ]), - ) - }) - } - for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { From 76a0f0f619d4c66d10b60f685e0641d1880244c8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 18:41:27 -0400 Subject: [PATCH 0064/1114] docs(httpapi): update migration spec to current state (#25173) --- packages/opencode/specs/effect/http-api.md | 44 ++++++++++++++-------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 6d6602e9466b..8eda0595db44 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -12,14 +12,16 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi ## Current State -- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono. -- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes. -- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation. -- `HttpApi` auth is independent of Hono auth. -- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`. +- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`. +- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup. +- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation. +- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono. +- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4. +- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module. - Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`. - Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`. - `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`. +- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`). ## Migration Rules @@ -122,10 +124,19 @@ Keep large or stateful groups for later: Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. +Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip: + +- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing). +- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing). +- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names). +- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix. +- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output. + Required before route deletion: -- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes. +- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path. - Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. +- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate. - Compare generated SDK output against `dev` for every route group deletion. - Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. @@ -365,25 +376,26 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. 9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. 10. [x] Bridge remaining session mutation and prompt routes. -11. [ ] Replace event SSE with non-Hono Effect HTTP. -12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP. -13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. -14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. -15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files. +11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15. +12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend. +13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend. +14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default. +15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files. ## Checklist - [x] Add first `HttpApi` JSON route slices. -- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. +- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.) - [x] Reuse existing Effect services in handlers. - [x] Provide auth, instance lookup, and observability in the Effect route layer. -- [x] Attach auth middleware in route modules. +- [x] Centralize auth via Effect `Config` for the Effect backend. - [x] Support `auth_token` as a query security scheme. - [x] Add bridge-level auth and instance tests. - [x] Complete exact Hono route inventory. - [x] Resolve implemented-but-unmounted route groups. - [x] Port remaining top-level JSON reads. -- [ ] Generate SDK/OpenAPI from Effect routes. -- [ ] Flip ported JSON routes to default-on with fallback. +- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. +- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default. +- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag. - [ ] Delete replaced Hono route implementations. -- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations. +- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono). From e0305e47f32ee6e686bc359c6ff931faab59b2af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 18:49:54 -0400 Subject: [PATCH 0065/1114] Protect HttpApi web UI fallback with auth (#25169) --- .../server/routes/instance/httpapi/server.ts | 19 +- packages/opencode/src/server/routes/ui.ts | 137 +++++++++--- .../opencode/test/server/httpapi-ui.test.ts | 208 +++++++++++++++++- 3 files changed, 312 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f62636bca84a..6e901269644a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,7 +1,8 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" @@ -38,7 +39,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin } from "@/server/cors" -import { UIRoutes } from "@/server/routes/ui" +import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { eventRoute } from "./event" @@ -120,18 +121,10 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -const uiRoutes = lazy(() => UIRoutes()) const uiRoute = HttpRouter.add("*", "/*", (request) => - Effect.promise(async () => - uiRoutes().fetch( - request.source instanceof Request - ? request.source - : new Request(new URL(request.originalUrl, "http://localhost"), { - method: request.method, - headers: request.headers, - }), - ), - ).pipe(Effect.map(HttpServerResponse.fromWeb)), + serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)), +).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), ) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 5e47e6bf716b..322f63cddb82 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,9 +1,13 @@ 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 { 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) @@ -12,44 +16,119 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI 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:` -export const UIRoutes = (): Hono => - new Hono().all("/*", async (c) => { - const embeddedWebUI = await embeddedUIPromise - const path = c.req.path +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 +} + +export async function serveUI(request: Request) { + const embeddedWebUI = await embeddedUI() + const path = new URL(request.url).pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) + + if (await fs.exists(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 new Response(new Uint8Array(await fs.readFile(match)), { headers }) + } + + return Response.json({ error: "Not Found" }, { status: 404 }) + } + + const response = await proxy(upstreamURL(path), { + raw: request, + headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), + }) + const match = response.headers.get("content-type")?.includes("text/html") + ? themePreloadHash(await response.clone().text()) + : undefined + const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" + response.headers.set("Content-Security-Policy", csp(hash)) + return response +} + +export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { + 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 c.json({ error: "Not Found" }, 404) + if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - if (await fs.exists(match)) { + const fs = yield* AppFileSystem.Service + if (yield* fs.existsSafe(match)) { const mime = getMimeType(match) ?? "text/plain" - c.header("Content-Type", mime) - if (mime.startsWith("text/html")) { - c.header("Content-Security-Policy", DEFAULT_CSP) - } - return c.body(new Uint8Array(await fs.readFile(match))) - } else { - return c.json({ error: "Not Found" }, 404) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(yield* fs.readFile(match), { headers }) } - } else { - const response = await proxy(`https://app.opencode.ai${path}`, { - raw: c.req.raw, - headers: { - ...Object.fromEntries(c.req.raw.headers.entries()), - host: "app.opencode.ai", - }, - }) - const match = response.headers.get("content-type")?.includes("text/html") - ? (await response.clone().text()).match( - /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, - ) - : undefined - const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" - response.headers.set("Content-Security-Policy", csp(hash)) - return response + + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } + + const response = yield* HttpClient.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/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 9ca8b49f1ba8..9dd2ea77c0a6 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,30 +1,130 @@ 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 { ConfigProvider, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { ServerAuthConfig, 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 { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - fetch: globalThis.fetch, + OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, + 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, } afterEach(() => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - globalThis.fetch = original.fetch + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) + restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) }) +function restoreEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} + +function app(input?: { password?: string; username?: string }) { + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function uiApp(input?: { + password?: string + username?: string + client?: Layer.Layer +}) { + const handler = HttpRouter.toWebHandler( + HttpRouter.add("*", "/*", (request) => + serveUIEffect(request).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide(input?.client ?? httpClient(new Response("ui"))), + ), + ).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(HttpServer.layerServices), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + ExperimentalHttpApiServer.context, + ) + }, + } +} + +function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + onRequest?.(request) + return Effect.succeed(HttpClientResponse.fromWeb(request, response)) + }), + ) +} + describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined - globalThis.fetch = ((input: RequestInfo | URL) => { - proxiedUrl = String(input instanceof Request ? input.url : input) - return Promise.resolve(new Response("opencode", { headers: { "content-type": "text/html" } })) - }) as typeof fetch - const response = await Server.Default().app.request("/") + const response = await uiApp({ + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } }), (request) => { + proxiedUrl = request.url + }), + }).request("/") expect(response.status).toBe(200) expect(response.headers.get("content-type")).toContain("text/html") @@ -32,14 +132,102 @@ describe("HttpApi UI fallback", () => { expect(proxiedUrl).toBe("https://app.opencode.ai/") }) + test("strips upstream transfer encoding headers from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + let proxiedUrl: string | undefined + + const response = await Effect.runPromise( + serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js"))).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js") + expect(response.headers.get("content-encoding")).toBeNull() + expect(response.headers.get("content-length")).not.toBe("999") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('ok')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - globalThis.fetch = (() => { - throw new Error("UI fallback should not handle matched API routes") - }) as unknown as typeof fetch const response = await Server.Default().app.request("/session/nope") expect(response.status).toBe(404) }) + + test("requires server password for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/") + + expect(response.status).toBe(401) + }) + + test("accepts auth token for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), + }).request( + `/?auth_token=${btoa("opencode:secret")}`, + ) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("opencode") + }) + + test("accepts basic auth for the web UI", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }) + + expect(response.status).toBe(200) + }) + + test("allows web UI preflight without auth", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + + const response = await app({ password: "secret", username: "opencode" }).request("/", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3000", + "access-control-request-method": "GET", + }, + }) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + }) }) From 247284b9af5b8dae84ef4746163badef863c9230 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 22:51:09 +0000 Subject: [PATCH 0066/1114] chore: generate --- .../server/routes/instance/httpapi/server.ts | 4 +--- packages/opencode/src/server/routes/ui.ts | 15 +++--------- .../opencode/test/server/httpapi-ui.test.ts | 24 +++++++++---------- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 6e901269644a..4ebc1a607868 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -123,9 +123,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe const uiRoute = HttpRouter.add("*", "/*", (request) => serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)), -).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), -) +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 322f63cddb82..a8c23460b39f 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -22,19 +22,13 @@ 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, - ) + 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), - ) + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) } function proxyResponseHeaders(headers: Record) { @@ -116,10 +110,7 @@ export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { 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") : ""), - ) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) return HttpServerResponse.text(body, { status: response.status, headers }) } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 9dd2ea77c0a6..d02564bda39c 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -12,7 +12,10 @@ 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 { + ServerAuthConfig, + 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 { Server } from "../../src/server/server" @@ -69,11 +72,7 @@ function app(input?: { password?: string; username?: string }) { } } -function uiApp(input?: { - password?: string - username?: string - client?: Layer.Layer -}) { +function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer }) { const handler = HttpRouter.toWebHandler( HttpRouter.add("*", "/*", (request) => serveUIEffect(request).pipe( @@ -121,9 +120,12 @@ describe("HttpApi UI fallback", () => { let proxiedUrl: string | undefined const response = await uiApp({ - client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } }), (request) => { - proxiedUrl = request.url - }), + client: httpClient( + new Response("opencode", { headers: { "content-type": "text/html" } }), + (request) => { + proxiedUrl = request.url + }, + ), }).request("/") expect(response.status).toBe(200) @@ -197,9 +199,7 @@ describe("HttpApi UI fallback", () => { password: "secret", username: "opencode", client: httpClient(new Response("opencode", { headers: { "content-type": "text/html" } })), - }).request( - `/?auth_token=${btoa("opencode:secret")}`, - ) + }).request(`/?auth_token=${btoa("opencode:secret")}`) expect(response.status).toBe(200) expect(await response.text()).toBe("opencode") From a12333310f795b92d524634d7b9e2daab8909dfc Mon Sep 17 00:00:00 2001 From: "Sewer." Date: Fri, 1 May 2026 00:05:56 +0100 Subject: [PATCH 0067/1114] fix(provider): split providerOptions key on dot for openai-compatible, openai, and anthropic providers (#25145) --- packages/opencode/src/provider/transform.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d47d1fe76ca3..d97e9cb87370 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1058,7 +1058,20 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a return result } - const key = sdkKey(model.api.npm) ?? model.providerID + // AI SDK packages that resolve providerOptionsName by splitting the + // provider name on "." (e.g. "wafer.ai" -> "wafer") need the same + // logic here so the key we write matches the key they read. + // Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys + // like "xai" or "cohere" - applying .split(".")[0] would break those. + const usesDotSplitOptions = + model.api.npm === "@ai-sdk/openai-compatible" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/anthropic" + const key = + sdkKey(model.api.npm) ?? + (usesDotSplitOptions + ? model.providerID.split(".")[0] + : model.providerID) // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. From 3aaac0098e195baebc045f4b1a7f84617008047c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 23:06:57 +0000 Subject: [PATCH 0068/1114] chore: generate --- packages/opencode/src/provider/transform.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d97e9cb87370..2fa7649c75f9 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1067,11 +1067,7 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a model.api.npm === "@ai-sdk/openai-compatible" || model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/anthropic" - const key = - sdkKey(model.api.npm) ?? - (usesDotSplitOptions - ? model.providerID.split(".")[0] - : model.providerID) + const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID) // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. From fc155e9fc52049881fc55e1c08eb05f795b7b504 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 19:24:10 -0400 Subject: [PATCH 0069/1114] Build HttpApi UI route from services (#25177) --- .../server/routes/instance/httpapi/server.ts | 17 +++-- packages/opencode/src/server/routes/ui.ts | 12 ++-- .../opencode/test/server/httpapi-ui.test.ts | 64 +++++++++++-------- 3 files changed, 57 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 4ebc1a607868..19ab4fbb1bce 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" @@ -121,9 +121,16 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -const uiRoute = HttpRouter.add("*", "/*", (request) => - serveUIEffect(request).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.provide(FetchHttpClient.layer)), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) +const uiRoute = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + const router = yield* HttpRouter.HttpRouter + yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) + }), +).pipe( + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), +) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ @@ -162,6 +169,8 @@ export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pip Workspace.defaultLayer, Worktree.defaultLayer, Bus.layer, + AppFileSystem.defaultLayer, + FetchHttpClient.layer, HttpServer.layerServices, ]), Layer.provideMerge(Observability.layer), diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index a8c23460b39f..403d85d66ca1 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -79,7 +79,10 @@ export async function serveUI(request: Request) { return response } -export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { +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 @@ -88,18 +91,17 @@ export function serveUIEffect(request: HttpServerRequest.HttpServerRequest) { const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - const fs = yield* AppFileSystem.Service - if (yield* fs.existsSafe(match)) { + 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* fs.readFile(match), { headers }) + return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) } return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } - const response = yield* HttpClient.execute( + const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), body: requestBody(request), diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index d02564bda39c..666d2f8f36ac 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -74,22 +74,26 @@ function app(input?: { password?: string; username?: string }) { function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer }) { const handler = HttpRouter.toWebHandler( - HttpRouter.add("*", "/*", (request) => - serveUIEffect(request).pipe( - Effect.provide(AppFileSystem.defaultLayer), - Effect.provide(input?.client ?? httpClient(new Response("ui"))), - ), + Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + const router = yield* HttpRouter.HttpRouter + yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) + }), ).pipe( Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), - Layer.provide(HttpServer.layerServices), - Layer.provide( + Layer.provide([ + AppFileSystem.defaultLayer, + input?.client ?? httpClient(new Response("ui")), + HttpServer.layerServices, ConfigProvider.layer( ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: input?.password, OPENCODE_SERVER_USERNAME: input?.username, }), ), - ), + ]), ), { disableLogger: true }, ).handler @@ -140,26 +144,32 @@ describe("HttpApi UI fallback", () => { let proxiedUrl: string | undefined const response = await Effect.runPromise( - serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js"))).pipe( - Effect.provide(AppFileSystem.defaultLayer), + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { fs, client }) + }).pipe( Effect.provide( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - proxiedUrl = request.url - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("console.log('ok')", { - headers: { - "content-encoding": "br", - "content-length": "999", - "content-type": "text/javascript", - }, - }), - ), - ) - }), + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + proxiedUrl = request.url + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("console.log('ok')", { + headers: { + "content-encoding": "br", + "content-length": "999", + "content-type": "text/javascript", + }, + }), + ), + ) + }), + ), ), ), Effect.map(HttpServerResponse.toWeb), From 8805104b8d3912d93081faf021e70dd15a73613f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 23:25:10 +0000 Subject: [PATCH 0070/1114] chore: generate --- .../opencode/src/server/routes/instance/httpapi/server.ts | 4 +--- packages/opencode/test/server/httpapi-ui.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 19ab4fbb1bce..adb7cb76922f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -128,9 +128,7 @@ const uiRoute = Layer.effectDiscard( const router = yield* HttpRouter.HttpRouter 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(ServerAuthConfig.defaultLayer)))) export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 666d2f8f36ac..9616b58b5c5c 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -147,7 +147,10 @@ describe("HttpApi UI fallback", () => { Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient - return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { fs, client }) + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), { + fs, + client, + }) }).pipe( Effect.provide( Layer.mergeAll( From e3134a2a995f3e30b9a21a0546ace1e8c4d3cc5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 19:28:46 -0400 Subject: [PATCH 0071/1114] refactor(session): align prompt input types with their schemas (#25178) --- .../instance/httpapi/handlers/session.ts | 8 ++--- packages/opencode/src/session/prompt.ts | 31 ++++++------------- 2 files changed, 14 insertions(+), 25 deletions(-) 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 c4def3e7429a..91afd0045f2b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -269,7 +269,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID, - } as unknown as SessionPrompt.PromptInput), + }), ), ), ).pipe( @@ -288,7 +288,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* Effect.sync(() => { bridge.fork( promptSvc - .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) + .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }) .pipe( Effect.catchCause((error) => Effect.sync(() => { @@ -309,14 +309,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput) + return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { params: { sessionID: SessionID } payload: typeof ShellPayload.Type }) { - return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput) + return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID }) }) const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c4d8673222f7..155b86b5833a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,7 +45,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" -import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect" +import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -127,7 +127,7 @@ export const layer = Layer.effect( const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context - const parts: PromptInput["parts"] = [{ type: "text", text: template }] + const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() yield* Effect.forEach( @@ -1012,7 +1012,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the case "file:": { log.info("file", { mime: part.mime }) const filepath = fileURLToPath(part.url) - if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory" + const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const { read } = yield* registry.named() const execRead = (args: Parameters[0], extra?: Tool.Context["extra"]) => { @@ -1031,7 +1031,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort()))) } - if (part.mime === "text/plain") { + if (mime === "text/plain") { let offset: number | undefined let limit: number | undefined const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") } @@ -1089,7 +1089,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), ) } else { - pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) + pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID }) } } else { const error = Cause.squash(exit.cause) @@ -1110,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return pieces } - if (part.mime === "application/x-directory") { + if (mime === "application/x-directory") { const args = { filePath: filepath } const exit = yield* execRead(args).pipe(Effect.exit) if (Exit.isFailure(exit)) { @@ -1146,7 +1146,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the synthetic: true, text: exit.value.output, }, - { ...part, messageID: info.id, sessionID: input.sessionID }, + { ...part, mime, messageID: info.id, sessionID: input.sessionID }, ] } @@ -1164,9 +1164,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the sessionID: input.sessionID, type: "file", url: - `data:${part.mime};base64,` + + `data:${mime};base64,` + Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), - mime: part.mime, + mime, filename: part.filename!, source: part.source, }, @@ -1700,18 +1700,7 @@ export const PromptInput = Schema.Struct({ ]).annotate({ discriminator: "type" }), ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -// `z.discriminatedUnion` erases the discriminated members' shapes back to -// `{}` when walked from the generic `z.ZodType` input. Restore the precise -// `parts` type from the exported Schema input types so callers see a proper -// tagged union. -type PartInputUnion = - | MessageV2.TextPartInput - | MessageV2.FilePartInput - | MessageV2.AgentPartInput - | MessageV2.SubtaskPartInput -export type PromptInput = Omit, "parts"> & { - parts: PartInputUnion[] -} +export type PromptInput = Schema.Schema.Type export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, From 510f01674a3cbdeac3e9096c99235af385a5e3c3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 23:29:58 +0000 Subject: [PATCH 0072/1114] chore: generate --- .../instance/httpapi/handlers/session.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 91afd0045f2b..39643e35ff35 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -287,19 +287,17 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const bridge = yield* EffectBridge.make() yield* Effect.sync(() => { bridge.fork( - promptSvc - .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }) - .pipe( - Effect.catchCause((error) => - Effect.sync(() => { - log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) - void Bus.publish(Session.Event.Error, { - sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ message: String(error) }).toObject(), - }) - }), - ), + promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) + void Bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ message: String(error) }).toObject(), + }) + }), ), + ), ) }) return HttpApiSchema.NoContent.make() From 2dd1f2d453fb048629e33903e18247283f0fc728 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 19:36:57 -0400 Subject: [PATCH 0073/1114] Avoid request-time HttpApi layer provisioning (#25179) --- .../server/routes/instance/httpapi/event.ts | 56 ++++++++++--------- .../instance/httpapi/middleware/proxy.ts | 9 +-- .../httpapi/middleware/workspace-routing.ts | 17 ++++-- .../server/routes/instance/httpapi/server.ts | 3 +- .../opencode/test/server/httpapi-ui.test.ts | 3 +- .../server/httpapi-workspace-routing.test.ts | 3 +- .../test/server/workspace-proxy.test.ts | 15 +++-- 7 files changed, 58 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 9f4ddde4c268..7d14480c3280 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -37,34 +37,38 @@ function eventData(data: unknown): Sse.Event { } } -export const eventRoute = HttpRouter.add( - "GET", - EventPaths.event, +export const eventRoute = HttpRouter.use((router) => Effect.gen(function* () { const bus = yield* Bus.Service - 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: {} })), - ) + yield* router.add( + "GET", + EventPaths.event, + Effect.gen(function* () { + 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: {} })), + ) - log.info("event connected") - return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), - ), - { - contentType: "text/event-stream", - headers: { - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, + log.info("event connected") + return HttpServerResponse.stream( + Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) + }), ) - }).pipe(Effect.provide(Bus.layer)), + }), ) 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 549dac40cc97..7c55fb3daad4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -1,7 +1,6 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" import { - FetchHttpClient, HttpBody, HttpClient, HttpClientRequest, @@ -66,12 +65,13 @@ function statusText(response: unknown) { } export function http( + client: HttpClient.HttpClient, url: string | URL, extra: HeadersInit | undefined, request: HttpServerRequest.HttpServerRequest, ): Effect.Effect { return Effect.gen(function* () { - const response = yield* HttpClient.execute( + const response = yield* client.execute( HttpClientRequest.make(request.method as never)(url, { headers: ProxyUtil.headers(request.headers as HeadersInit, extra), body: requestBody(request), @@ -86,10 +86,7 @@ export function http( statusText: statusText(response), headers, }) - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))), - ) + }).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 })))) } export * as HttpApiProxy from "./proxy" 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 ce384ad18c75..30edbc782b46 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 @@ -9,7 +9,7 @@ import * as Fence from "@/server/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -95,6 +95,7 @@ function resolveTarget(workspace: Workspace.Info): Effect.Effect { } function proxyRemote( + client: HttpClient.HttpClient, request: HttpServerRequest.HttpServerRequest, workspace: Workspace.Info, target: RemoteTarget, @@ -111,7 +112,7 @@ function proxyRemote( const proxyURL = workspaceProxyURL(target.url, url) const headers = request.headers as Record if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) - const response = yield* HttpApiProxy.http(proxyURL, target.headers, request) + const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request) const sync = Fence.parse(new Headers(response.headers)) if (sync) { const syncFailure = yield* Fence.waitEffect( @@ -163,18 +164,20 @@ function planRequest( } function routeWorkspace( + client: HttpClient.HttpClient, effect: Effect.Effect, plan: RequestPlan, ): Effect.Effect { return RequestPlan.$match(plan, { MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), - Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url), + Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url), Local: ({ directory, workspaceID }) => effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), }) } function routeHttpApiWorkspace( + client: HttpClient.HttpClient, effect: Effect.Effect, ): Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -188,7 +191,7 @@ function routeHttpApiWorkspace( ? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void)) : undefined const plan = yield* planRequest(request, session?.workspaceID) - return yield* routeWorkspace(effect, plan) + return yield* routeWorkspace(client, effect, plan) }) } @@ -197,8 +200,9 @@ export const workspaceRoutingLayer = Layer.effect( Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor const workspace = yield* Workspace.Service + const client = yield* HttpClient.HttpClient return WorkspaceRoutingMiddleware.of((effect) => - routeHttpApiWorkspace(effect).pipe( + routeHttpApiWorkspace(client, effect).pipe( Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), Effect.provideService(Workspace.Service, workspace), ), @@ -210,11 +214,12 @@ export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: Works Effect.gen(function* () { const makeWebSocket = yield* Socket.WebSocketConstructor const workspace = yield* Workspace.Service + const client = yield* HttpClient.HttpClient return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const plan = yield* planRequest(request) - return yield* routeWorkspace(effect, plan) + return yield* routeWorkspace(client, effect, plan) }).pipe( Effect.provideService(Socket.WebSocketConstructor, makeWebSocket), Effect.provideService(Workspace.Service, workspace), diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index adb7cb76922f..43671ff74fe8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -121,11 +121,10 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -const uiRoute = Layer.effectDiscard( +const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient - const router = yield* HttpRouter.HttpRouter yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 9616b58b5c5c..7c9739f51ded 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -74,11 +74,10 @@ function app(input?: { password?: string; username?: string }) { function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer }) { const handler = HttpRouter.toWebHandler( - Layer.effectDiscard( + HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* AppFileSystem.Service const client = yield* HttpClient.HttpClient - const router = yield* HttpRouter.HttpRouter yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 57312678f66d..5d92635fbca5 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -3,6 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" import { Context, Effect, Layer, Queue } from "effect" import { + FetchHttpClient, HttpClient, HttpClientRequest, HttpRouter, @@ -66,7 +67,7 @@ type TestHandler = ( ) => Effect.Effect const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe( - Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]), ) const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index 3e52ade6380e..e20cd70bd41f 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -1,8 +1,8 @@ -import { NodeHttpServer } from "@effect/platform-node" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" import Http from "node:http" import { describe, expect } from "bun:test" import { Context, Effect, Layer, Queue } from "effect" -import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" import { testEffect } from "../lib/effect" @@ -13,6 +13,8 @@ function serverUrl() { const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), + NodeServices.layer, + FetchHttpClient.layer, Socket.layerWebSocketConstructorGlobal, ) const it = testEffect(testServerLayer) @@ -79,7 +81,8 @@ describe("HttpApi workspace proxy", () => { const request = HttpServerRequest.fromWeb( new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), ) - const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) expect(response.status).toBe(201) const client = HttpServerResponse.toClientResponse(response) @@ -97,7 +100,8 @@ describe("HttpApi workspace proxy", () => { it.live("returns 500 when remote is unreachable", () => Effect.gen(function* () { const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything")) - const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request) + const httpClient = yield* HttpClient.HttpClient + const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request) expect(response.status).toBe(500) }), @@ -122,7 +126,8 @@ describe("HttpApi workspace proxy", () => { }, }), ) - yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request) + const httpClient = yield* HttpClient.HttpClient + yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request) expect(forwarded["x-opencode-directory"]).toBeUndefined() expect(forwarded["x-opencode-workspace"]).toBeUndefined() From 96a0dd6b040f64071f2bf6f940d8dbd4402db3c0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Apr 2026 23:37:58 +0000 Subject: [PATCH 0074/1114] chore: generate --- .../server/routes/instance/httpapi/middleware/proxy.ts | 8 +------- packages/opencode/test/server/workspace-proxy.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 8 deletions(-) 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 7c55fb3daad4..e354dccbfa64 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -1,12 +1,6 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" -import { - HttpBody, - HttpClient, - HttpClientRequest, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { diff --git a/packages/opencode/test/server/workspace-proxy.test.ts b/packages/opencode/test/server/workspace-proxy.test.ts index e20cd70bd41f..732f2560a27d 100644 --- a/packages/opencode/test/server/workspace-proxy.test.ts +++ b/packages/opencode/test/server/workspace-proxy.test.ts @@ -82,7 +82,12 @@ describe("HttpApi workspace proxy", () => { new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }), ) const httpClient = yield* HttpClient.HttpClient - const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request) + const response = yield* HttpApiProxy.http( + httpClient, + `${url}/session/abc?keep=yes`, + { "x-extra": "injected" }, + request, + ) expect(response.status).toBe(201) const client = HttpServerResponse.toClientResponse(response) From 96f4da1e1d060df9a07ad9ec89ab7d5e27905e14 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 20:02:46 -0400 Subject: [PATCH 0075/1114] Serve instance events through HttpApiBuilder (#25182) --- .../server/routes/instance/httpapi/event.ts | 68 +++++++++---------- .../server/routes/instance/httpapi/server.ts | 21 +++--- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 7d14480c3280..f13e3251bf73 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -2,8 +2,8 @@ import { Bus } from "@/bus" import * as Log from "@opencode-ai/core/util/log" import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" -import { HttpRouter, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" const log = Log.create({ service: "server" }) @@ -16,7 +16,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { - success: Schema.Unknown, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ identifier: "event.subscribe", @@ -37,38 +37,38 @@ function eventData(data: unknown): Sse.Event { } } -export const eventRoute = HttpRouter.use((router) => +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: {} })), + ) + + log.info("event connected") + return HttpServerResponse.stream( + Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) +} + +export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service - yield* router.add( - "GET", - EventPaths.event, - Effect.gen(function* () { - 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: {} })), - ) - - log.info("event connected") - return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), - ), - { - contentType: "text/event-stream", - headers: { - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ) - }), - ) + return handlers.handleRaw("subscribe", Effect.fn("EventHttpApi.subscribe")(function* () { + return eventResponse(bus) + })) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 43671ff74fe8..dabda5aaafa3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -42,7 +42,7 @@ import { isAllowedCorsOrigin } from "@/server/cors" import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" -import { eventRoute } from "./event" +import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" import { experimentalHandlers } from "./handlers/experimental" @@ -86,6 +86,14 @@ const cors = HttpRouter.middleware( ) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const instanceRouterLayer = authorizationRouterMiddleware + .combine(instanceRouterMiddleware) + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) +const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( + Layer.provide(eventHandlers), + Layer.provide(instanceRouterLayer), +) const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( Layer.provide([ configHandlers, @@ -105,13 +113,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( - Layer.provide( - authorizationRouterMiddleware - .combine(instanceRouterMiddleware) - .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)), - ), +const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe( + Layer.provide(instanceRouterLayer), ) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ @@ -129,7 +132,7 @@ const uiRoute = HttpRouter.use((router) => }), ).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) -export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes, uiRoute).pipe( +export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ cors, runtime, From 1b76bec0e26dddfa1e4c71161dbae20c711ce109 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 00:03:55 +0000 Subject: [PATCH 0076/1114] chore: generate --- .../opencode/src/server/routes/instance/httpapi/event.ts | 9 ++++++--- .../src/server/routes/instance/httpapi/server.ts | 4 +--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index f13e3251bf73..25e810753e21 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -67,8 +67,11 @@ function eventResponse(bus: Bus.Interface) { export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) => Effect.gen(function* () { const bus = yield* Bus.Service - return handlers.handleRaw("subscribe", Effect.fn("EventHttpApi.subscribe")(function* () { - return eventResponse(bus) - })) + return handlers.handleRaw( + "subscribe", + Effect.fn("EventHttpApi.subscribe")(function* () { + return eventResponse(bus) + }), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index dabda5aaafa3..d453f458a46e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -113,9 +113,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( ]), ) -const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe( - Layer.provide(instanceRouterLayer), -) +const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), From 451650b584e9eee3b8c7c488a7b3daafd6bdf487 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 20:04:00 -0400 Subject: [PATCH 0077/1114] refactor(httpapi): preserve typed errors in session prompt handlers (#25181) --- packages/opencode/src/effect/bridge.ts | 11 +++++++- .../instance/httpapi/handlers/session.ts | 28 ++++++++----------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 281cfa010c01..3c310129f151 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -1,4 +1,4 @@ -import { Effect, Fiber } from "effect" +import { Effect, Exit, Fiber } from "effect" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Instance, type InstanceContext } from "@/project/instance" import type { WorkspaceID } from "@/control-plane/schema" @@ -9,6 +9,7 @@ import { attachWith } from "./run-service" export interface Shape { readonly promise: (effect: Effect.Effect) => Promise readonly fork: (effect: Effect.Effect) => Fiber.Fiber + readonly run: (effect: Effect.Effect) => Effect.Effect } function restore(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R { @@ -43,6 +44,14 @@ export function make(): Effect.Effect { restore(instance, workspace, () => Effect.runPromise(wrap(effect))), fork: (effect: Effect.Effect) => restore(instance, workspace, () => Effect.runFork(wrap(effect))), + run: (effect: Effect.Effect) => + Effect.callback((resume) => { + restore(instance, workspace, () => + Effect.runPromiseExit(wrap(effect)).then((exit) => + resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)), + ), + ) + }), } satisfies Shape }) } 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 39643e35ff35..e08e09495ae7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,9 +18,8 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" -import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import { Effect, Schema } from "effect" +import { Cause, Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -40,8 +39,6 @@ import { UpdatePayload, } from "../groups/session" -const log = Log.create({ service: "server" }) - const mapNotFound = (self: Effect.Effect) => self.pipe( Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))), @@ -63,6 +60,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service + const bus = yield* Bus.Service const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { const instance = yield* InstanceState.context @@ -264,13 +262,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const bridge = yield* EffectBridge.make() return HttpServerResponse.stream( Stream.fromEffect( - Effect.promise(() => - bridge.promise( - promptSvc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }), - ), + bridge.run( + promptSvc.prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }), ), ).pipe( Stream.map((message) => JSON.stringify(message)), @@ -288,12 +284,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* Effect.sync(() => { bridge.fork( promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( - Effect.catchCause((error) => - Effect.sync(() => { - log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) - void Bus.publish(Session.Event.Error, { + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* bus.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ message: String(error) }).toObject(), + error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), }) }), ), From a499fe2b1751f79a44808066ec053c36071a9f28 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 20:33:04 -0400 Subject: [PATCH 0078/1114] refactor(tool/read): yield InstanceState.context instead of reading ALS (#25183) --- packages/opencode/src/tool/read.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fb386f579050..ef33a48deac0 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -7,7 +7,7 @@ import * as Tool from "./tool" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "@/lsp/lsp" import DESCRIPTION from "./read.txt" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" @@ -157,14 +157,15 @@ export const ReadTool = Tool.define( 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)) { - filepath = path.resolve(Instance.directory, filepath) + filepath = path.resolve(instance.directory, filepath) } if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } - const title = path.relative(Instance.worktree, filepath) + const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( Effect.catchIf( From 5c2e06f353eb00e1c4576ef24fb208eb3af935f8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 20:48:14 -0400 Subject: [PATCH 0079/1114] Document HttpApi route patterns (#25188) --- .../server/routes/instance/httpapi/AGENTS.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/AGENTS.md diff --git a/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md new file mode 100644 index 000000000000..757d7aed0c3f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/AGENTS.md @@ -0,0 +1,35 @@ +# HttpApi Route Patterns + +Use `HttpApiBuilder.group(...)` for normal HTTP endpoints, including streaming HTTP responses such as server-sent events. Handlers should yield stable services once while building the handler layer, then close over those services in endpoint implementations. + +```ts +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => + Effect.gen(function* () { + const session = yield* Session.Service + + return handlers.handle("list", () => session.list()) + }), +) +``` + +For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type. + +Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks. + +```ts +export const rawRoute = HttpRouter.use((router) => + Effect.gen(function* () { + const pty = yield* Pty.Service + + yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty)) + }), +) +``` + +Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request. + +Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services. + +Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction. + +When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled. From 668d77bb4e5955eb56a81b3db13ea1dd74400cc2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:01:06 -0400 Subject: [PATCH 0080/1114] refactor(tool): yield InstanceState context (#25199) --- packages/opencode/src/session/prompt.ts | 6 ++++-- packages/opencode/src/session/session.ts | 8 ++++---- packages/opencode/src/tool/apply_patch.ts | 20 +++++++++++--------- packages/opencode/src/tool/bash.ts | 21 ++++++++++++++------- packages/opencode/src/tool/edit.ts | 11 ++++++----- packages/opencode/src/tool/lsp.ts | 7 ++++--- packages/opencode/src/tool/plan.ts | 5 +++-- packages/opencode/src/tool/write.ts | 9 +++++---- 8 files changed, 51 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 155b86b5833a..58edffe3f9bc 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -256,7 +256,8 @@ export const layer = Layer.effect( const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { - const plan = Session.plan(input.session) + const ctx = yield* InstanceState.context + const plan = Session.plan(input.session, ctx) if (!(yield* fsys.existsSafe(plan))) return input.messages const part = yield* sessions.updatePart({ id: PartID.ascending(), @@ -272,7 +273,8 @@ export const layer = Layer.effect( if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages - const plan = Session.plan(input.session) + const ctx = yield* InstanceState.context + const plan = Session.plan(input.session, ctx) const exists = yield* fsys.existsSafe(plan) if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) const part = yield* sessions.updatePart({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5534976e399c..7e6016b87fe8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -26,7 +26,7 @@ import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" -import { Instance } from "../project/instance" +import { Instance, type InstanceContext } from "../project/instance" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" @@ -311,9 +311,9 @@ export const Event = { ), } -export function plan(input: { slug: string; time: { created: number } }) { - const base = Instance.project.vcs - ? path.join(Instance.worktree, ".opencode", "plans") +export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { + const base = instance.project.vcs + ? path.join(instance.worktree, ".opencode", "plans") : path.join(Global.Path.data, "plans") return path.join(base, [input.time.created, input.slug].join("-") + ".md") } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 2de18ad081c7..916e11f1e3e5 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import * as Tool from "./tool" import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { Patch } from "../patch" import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectoryEffect } from "./external-directory" @@ -52,6 +52,8 @@ export const ApplyPatchTool = Tool.define( return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found")) } + const instance = yield* InstanceState.context + // Validate file paths and check permissions const fileChanges: Array<{ filePath: string @@ -68,7 +70,7 @@ export const ApplyPatchTool = Tool.define( let totalDiff = "" for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) + const filePath = path.resolve(instance.directory, hunk.path) yield* assertExternalDirectoryEffect(ctx, filePath) switch (hunk.type) { @@ -133,7 +135,7 @@ export const ApplyPatchTool = Tool.define( if (change.removed) deletions += change.count || 0 } - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined yield* assertExternalDirectoryEffect(ctx, movePath) fileChanges.push({ @@ -187,7 +189,7 @@ export const ApplyPatchTool = Tool.define( // Build per-file metadata for UI rendering (used for both permission and result) const files = fileChanges.map((change) => ({ filePath: change.filePath, - relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + relativePath: path.relative(instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, patch: change.diff, additions: change.additions, @@ -196,7 +198,7 @@ export const ApplyPatchTool = Tool.define( })) // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) + const relativePaths = fileChanges.map((c) => path.relative(instance.worktree, c.filePath).replaceAll("\\", "/")) yield* ctx.ask({ permission: "edit", patterns: relativePaths, @@ -267,13 +269,13 @@ export const ApplyPatchTool = Tool.define( // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `A ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` } if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `D ${path.relative(instance.worktree, change.filePath).replaceAll("\\", "/")}` } const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` + return `M ${path.relative(instance.worktree, target).replaceAll("\\", "/")}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -282,7 +284,7 @@ export const ApplyPatchTool = Tool.define( const target = change.movePath ?? change.filePath const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? []) if (!block) continue - const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/") + const rel = path.relative(instance.worktree, target).replaceAll("\\", "/") output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}` } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index c32c3963baf4..c50b259f7a40 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 } from "../project/instance" +import { Instance, type InstanceContext } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -363,7 +363,13 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("BashTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + instance: InstanceContext, + ) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -379,7 +385,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)) continue + if (!resolved || Instance.containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) } @@ -589,9 +595,10 @@ export const BashTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const executeInstance = yield* InstanceState.context const cwd = params.workdir - ? yield* resolvePath(params.workdir, Instance.directory, shell) - : Instance.directory + ? yield* resolvePath(params.workdir, executeInstance.directory, shell) + : executeInstance.directory if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -602,8 +609,8 @@ export const BashTool = Tool.define( const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) => Effect.sync(() => tree.delete()), ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell) - if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) + if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2463d48fae86..ea3aac34807d 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -13,7 +13,7 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { Format } from "../format" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -76,9 +76,10 @@ export const EditTool = Tool.define( throw new Error("No changes to apply: oldString and newString are identical.") } + const instance = yield* InstanceState.context const filePath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(Instance.directory, params.filePath) + : path.join(instance.directory, params.filePath) yield* assertExternalDirectoryEffect(ctx, filePath) let diff = "" @@ -96,7 +97,7 @@ export const EditTool = Tool.define( diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [path.relative(instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -139,7 +140,7 @@ export const EditTool = Tool.define( ) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [path.relative(instance.worktree, filePath)], always: ["*"], metadata: { filepath: filePath, @@ -201,7 +202,7 @@ export const EditTool = Tool.define( diff, filediff, }, - title: `${path.relative(Instance.worktree, filePath)}`, + title: `${path.relative(instance.worktree, filePath)}`, output, } }), diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 3a555c2ce826..6f1532ca0c64 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -3,7 +3,7 @@ import * as Tool from "./tool" import path from "path" import { LSP } from "@/lsp/lsp" import DESCRIPTION from "./lsp.txt" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { pathToFileURL } from "url" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -44,7 +44,8 @@ export const LspTool = Tool.define( parameters: Parameters, execute: (args: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + const instance = yield* InstanceState.context + const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(instance.directory, args.filePath) yield* assertExternalDirectoryEffect(ctx, file) const meta = args.operation === "workspaceSymbol" @@ -61,7 +62,7 @@ export const LspTool = Tool.define( const uri = pathToFileURL(file).href const position = { file, line: args.line - 1, character: args.character - 1 } - const relPath = path.relative(Instance.worktree, file) + const relPath = path.relative(instance.worktree, file) const detail = args.operation === "workspaceSymbol" ? "" diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index dc49ef48baba..d5195376b316 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -5,7 +5,7 @@ import { Question } from "../question" import { Session } from "@/session/session" import { MessageV2 } from "../session/message-v2" import { Provider } from "@/provider/provider" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { type SessionID, MessageID, PartID } from "../session/schema" import EXIT_DESCRIPTION from "./plan-exit.txt" @@ -30,8 +30,9 @@ export const PlanExitTool = Tool.define( parameters: Parameters, execute: (_params: {}, ctx: Tool.Context) => Effect.gen(function* () { + const instance = yield* InstanceState.context const info = yield* session.get(ctx.sessionID) - const plan = path.relative(Instance.worktree, Session.plan(info)) + const plan = path.relative(instance.worktree, Session.plan(info, instance)) const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: [ diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 09c3a38055f9..c2be73ab1cdb 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -10,7 +10,7 @@ import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" import * as Bom from "@/util/bom" @@ -37,9 +37,10 @@ export const WriteTool = Tool.define( parameters: Parameters, execute: (params: { content: string; filePath: string }, ctx: Tool.Context) => Effect.gen(function* () { + const instance = yield* InstanceState.context const filepath = path.isAbsolute(params.filePath) ? params.filePath - : path.join(Instance.directory, params.filePath) + : path.join(instance.directory, params.filePath) yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) @@ -52,7 +53,7 @@ export const WriteTool = Tool.define( const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: { filepath, @@ -89,7 +90,7 @@ export const WriteTool = Tool.define( } return { - title: path.relative(Instance.worktree, filepath), + title: path.relative(instance.worktree, filepath), metadata: { diagnostics, filepath, From bc805b30019ae8c66889c4780c5478346a062b65 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:26:32 -0400 Subject: [PATCH 0081/1114] Pass CORS options to HttpApi backend (#25201) --- packages/opencode/src/server/cors.ts | 4 +- packages/opencode/src/server/middleware.ts | 4 +- .../server/routes/instance/httpapi/server.ts | 118 ++++++++++-------- packages/opencode/src/server/server.ts | 28 +++-- .../opencode/test/server/httpapi-cors.test.ts | 27 ++++ 5 files changed, 113 insertions(+), 68 deletions(-) diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 8ae945b75244..62a181af3a54 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,6 +1,8 @@ const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ -export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) { +export type CorsOptions = { readonly cors?: ReadonlyArray } + +export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true if (input.startsWith("http://127.0.0.1:")) return true diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 433f301ae403..d2cc9b538dc3 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth" import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" -import { isAllowedCorsOrigin } from "./cors" +import { isAllowedCorsOrigin, type CorsOptions } from "./cors" const log = Log.create({ service: "server" }) @@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M } } -export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler { +export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { return cors({ maxAge: 86_400, origin(input) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index d453f458a46e..e6dedfe2c4e8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -38,7 +38,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin } from "@/server/cors" +import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/routes/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" @@ -77,13 +77,14 @@ const runtime = HttpRouter.middleware()( ), ).layer -const cors = HttpRouter.middleware( - HttpMiddleware.cors({ - allowedOrigins: isAllowedCorsOrigin, - maxAge: 86_400, - }), - { global: true }, -) +const cors = (corsOptions?: CorsOptions) => + HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions), + maxAge: 86_400, + }), + { global: true }, + ) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) const instanceRouterLayer = authorizationRouterMiddleware @@ -130,55 +131,68 @@ const uiRoute = HttpRouter.use((router) => }), ).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) -export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( - Layer.provide([ - cors, - runtime, - Account.defaultLayer, - Agent.defaultLayer, - Auth.defaultLayer, - Command.defaultLayer, - Config.defaultLayer, - File.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Installation.defaultLayer, - MCP.defaultLayer, - Permission.defaultLayer, - Project.defaultLayer, - ProviderAuth.defaultLayer, - Provider.defaultLayer, - Pty.defaultLayer, - Question.defaultLayer, - Ripgrep.defaultLayer, - Session.defaultLayer, - SessionCompaction.defaultLayer, - SessionPrompt.defaultLayer, - SessionRevert.defaultLayer, - SessionShare.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - SessionSummary.defaultLayer, - SyncEvent.defaultLayer, - Skill.defaultLayer, - Todo.defaultLayer, - ToolRegistry.defaultLayer, - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.defaultLayer, - Bus.layer, - AppFileSystem.defaultLayer, - FetchHttpClient.layer, - HttpServer.layerServices, - ]), - Layer.provideMerge(Observability.layer), -) +export function createRoutes(corsOptions?: CorsOptions) { + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( + Layer.provide([ + cors(corsOptions), + runtime, + Account.defaultLayer, + Agent.defaultLayer, + Auth.defaultLayer, + Command.defaultLayer, + Config.defaultLayer, + File.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Installation.defaultLayer, + MCP.defaultLayer, + Permission.defaultLayer, + Project.defaultLayer, + ProviderAuth.defaultLayer, + Provider.defaultLayer, + Pty.defaultLayer, + Question.defaultLayer, + Ripgrep.defaultLayer, + Session.defaultLayer, + SessionCompaction.defaultLayer, + SessionPrompt.defaultLayer, + SessionRevert.defaultLayer, + SessionShare.defaultLayer, + SessionRunState.defaultLayer, + SessionStatus.defaultLayer, + SessionSummary.defaultLayer, + SyncEvent.defaultLayer, + Skill.defaultLayer, + Todo.defaultLayer, + ToolRegistry.defaultLayer, + Vcs.defaultLayer, + Workspace.defaultLayer, + Worktree.defaultLayer, + Bus.layer, + AppFileSystem.defaultLayer, + FetchHttpClient.layer, + HttpServer.layerServices, + ]), + Layer.provideMerge(Observability.layer), + ) +} + +export const routes = createRoutes() -export const webHandler = lazy(() => +const defaultWebHandler = lazy(() => HttpRouter.toWebHandler(routes, { memoMap, middleware: disposeMiddleware, }), ) +export function webHandler(corsOptions?: CorsOptions) { + if (!corsOptions?.cors?.length) return defaultWebHandler() + return HttpRouter.toWebHandler(createRoutes(corsOptions), { + // Server-level CORS options are dynamic; don't reuse the default route layer memoized without them. + memoMap: Layer.makeMemoMapUnsafe(), + middleware: disposeMiddleware, + }) +} + export * as ExperimentalHttpApiServer from "./server" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e4aeda798944..a1e821fb7060 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -18,6 +18,7 @@ import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import * as ServerBackend from "./backend" +import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -38,6 +39,13 @@ type ServerApp = { request(input: string | URL | Request, init?: RequestInit): Response | Promise } +type ListenOptions = CorsOptions & { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string +} + const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), ) @@ -54,14 +62,14 @@ export const Default = () => { return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() } -function create(opts: { cors?: string[] }) { +function create(opts: ListenOptions) { const selected = select() return selected.backend === "effect-httpapi" - ? withBackend(selected, createHttpApi()) + ? withBackend(selected, createHttpApi(opts)) : withBackend(selected, createHono(opts, selected)) } -export function Legacy(opts: { cors?: string[] } = {}) { +export function Legacy(opts: CorsOptions = {}) { return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) } @@ -74,8 +82,8 @@ function withBackend(selection: return built } -function createHttpApi() { - const handler = ExperimentalHttpApiServer.webHandler().handler +function createHttpApi(corsOptions?: CorsOptions) { + const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { @@ -89,7 +97,7 @@ function createHttpApi() { } function createHono( - opts: { cors?: string[] }, + opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), ) { const backendAttributes = ServerBackend.attributes(selection) @@ -151,13 +159,7 @@ export async function openapi() { export let url: URL -export async function listen(opts: { - port: number - hostname: string - mdns?: boolean - mdnsDomain?: string - cors?: string[] -}): Promise { +export async function listen(opts: ListenOptions): Promise { const built = create(opts) const server = await built.runtime.listen(opts) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 3330cfdd11aa..2e5520cafa0e 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Config, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" @@ -61,4 +62,30 @@ describe("HttpApi CORS", () => { expect(response.headers["access-control-allow-headers"]).toBe("authorization") }), ) + + it.live("uses custom CORS origins passed to the server", () => + Effect.gen(function* () { + const listener = yield* Effect.acquireRelease( + Effect.promise(() => + Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }), + ), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + const response = yield* Effect.promise(() => + fetch(new URL(InstancePaths.path, listener.url), { + method: "OPTIONS", + headers: { + origin: "https://custom.example", + "access-control-request-method": "GET", + "access-control-request-headers": "authorization", + }, + }), + ) + + expect(response.status).toBe(204) + expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example") + expect(response.headers.get("access-control-allow-headers")).toBe("authorization") + }), + ) }) From a9d399699eb6d3edae544b942a098bf0e5895aed Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 1 May 2026 03:27:38 +0200 Subject: [PATCH 0082/1114] fix(desktop): Prevent Model response Interruption when opening settings dialog (#25114) --- packages/app/src/components/settings-general.tsx | 1 + packages/opencode/src/config/config.ts | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8060ae94d91a..535bd72064e2 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -329,6 +329,7 @@ export const SettingsGeneral: Component = () => { label={(o) => o.label} onSelect={(option) => { if (!option) return + if (option.value === currentShell()) return globalSync.updateConfig({ shell: option.value }) }} variant="secondary" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 817f8c3e3870..c79e950e909d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -759,18 +759,23 @@ export const layer = Layer.effect( const patch = writableGlobal(config) let next: Info + let changed: boolean if (!file.endsWith(".jsonc")) { const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) const merged = mergeDeep(writable(existing), patch) - yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) + const serialized = JSON.stringify(merged, null, 2) + changed = serialized !== before + if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) next = merged } else { const updated = patchJsonc(before, patch) next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) - yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + changed = updated !== before + if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - yield* invalidate() + // Only tear down running instances if the config actually changed. + if (changed) yield* invalidate() return next }) From 6d4629b566f2ab7e689e9ea0600dfbce63b68952 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 01:28:37 +0000 Subject: [PATCH 0083/1114] chore: generate --- packages/opencode/src/server/server.ts | 5 +---- packages/opencode/test/server/httpapi-cors.test.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a1e821fb7060..6ebc8dc487f6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -96,10 +96,7 @@ function createHttpApi(corsOptions?: CorsOptions) { } } -function createHono( - opts: CorsOptions, - selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), -) { +function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { const backendAttributes = ServerBackend.attributes(selection) const app = new Hono() .onError(ErrorMiddleware) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 2e5520cafa0e..72265ad9bd63 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -66,9 +66,7 @@ describe("HttpApi CORS", () => { it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease( - Effect.promise(() => - Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }), - ), + Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), (listener) => Effect.promise(() => listener.stop(true)), ) From 8aa8798e076b7f3ca40c8dd0b34256413e049dae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:29:28 -0400 Subject: [PATCH 0084/1114] refactor(session): yield instance context in llm (#25200) --- packages/opencode/src/session/llm.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index b8b8911858fc..58677debc00e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -7,7 +7,7 @@ import { mergeDeep, pipe } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" -import { Instance } from "@/project/instance" +import { InstanceState } from "@/effect/instance-state" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" @@ -268,7 +268,7 @@ const live: Layer.Layer< const bridge = yield* EffectBridge.make() const approvedToolsForSession = new Set() - workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { + workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => { const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] // Auto-approve tools that were already approved in this session // (prevents infinite approval loops for server-side MCP tools) @@ -330,6 +330,10 @@ const live: Layer.Layer< }) : undefined + const opencodeProjectID = input.model.providerID.startsWith("opencode") + ? (yield* InstanceState.context).project.id + : undefined + return streamText({ onError(error) { l.error("stream error", { @@ -369,7 +373,7 @@ const live: Layer.Layer< headers: { ...(input.model.providerID.startsWith("opencode") ? { - "x-opencode-project": Instance.project.id, + "x-opencode-project": opencodeProjectID, "x-opencode-session": input.sessionID, "x-opencode-request": input.user.id, "x-opencode-client": Flag.OPENCODE_CLIENT, From e8a194a2bb39515546b0340151d1a1631e537bff Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:36:19 -0400 Subject: [PATCH 0085/1114] test(effect): stabilize runner active shell check (#25203) --- packages/opencode/test/effect/runner.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 97ca9f6161b7..0f5783bfc4a6 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -263,14 +263,25 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) - yield* Effect.sleep("10 millis") + const started = yield* Deferred.make() + const fiber = yield* runner + .ensureRunning( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never.pipe(Effect.as("x")) + }), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("250 millis")) + yield* Effect.gen(function* () { + while (runner.state._tag !== "Running") yield* Effect.yieldNow + }).pipe(Effect.timeout("250 millis")) const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel - yield* Fiber.await(fiber) + yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) }), ) From ce3b0988c416d4505309dbad7e23e9439645aafa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:44:52 -0400 Subject: [PATCH 0086/1114] refactor(project): yield instance context in bootstrap (#25204) --- packages/opencode/src/project/bootstrap.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 2ea07bb8de11..ae52ac55034a 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -7,7 +7,7 @@ import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" -import { Instance } from "./instance" +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" @@ -15,7 +15,8 @@ import * as Effect from "effect/Effect" import { Config } from "@/config/config" export const InstanceBootstrap = Effect.gen(function* () { - Log.Default.info("bootstrapping", { directory: Instance.directory }) + 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. @@ -32,10 +33,11 @@ export const InstanceBootstrap = Effect.gen(function* () { ].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(Instance.project.id) + Project.setInitialized(projectID) } }), ) From a083c88e87ed7bc652209355f7d5ac7e76f15b5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:45:02 -0400 Subject: [PATCH 0087/1114] refactor(sync): capture instance context for publish (#25206) --- packages/opencode/src/sync/index.ts | 42 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index ebf7543af103..324c4ec45f1e 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -4,9 +4,9 @@ import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" import { Bus as ProjectBus } from "@/bus" import { BusEvent } from "@/bus/bus-event" -import { Instance } from "@/project/instance" +import type { InstanceContext } from "@/project/instance" import { EventSequenceTable, EventTable } from "./event.sql" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" @@ -14,6 +14,7 @@ import { zodObject } from "@/util/effect-zod" import type { DeepMutable } from "@/util/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" +import { InstanceState } from "@/effect/instance-state" // Keep `Event["data"]` mutable because projectors mutate the persisted shape // when writing to the database. Bus payloads (`Properties`) stay readonly — @@ -47,6 +48,10 @@ export type SerializedEvent = Event & type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise +type PublishContext = { + instance?: InstanceContext + workspace?: WorkspaceID +} export interface Interface { readonly run: ( @@ -87,7 +92,14 @@ export const layer = Layer.effect(Service)( ) } - process(def, event, { publish: !!options?.publish }) + const publish = !!options?.publish + const context = publish + ? { + instance: yield* InstanceState.context, + workspace: yield* InstanceState.workspaceID, + } + : undefined + process(def, event, { publish, context }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -122,6 +134,12 @@ export const layer = Layer.effect(Service)( } const { publish = true } = options || {} + const context = publish + ? { + instance: yield* InstanceState.context, + workspace: yield* InstanceState.workspaceID, + } + : undefined // Note that this is an "immediate" transaction which is critical. // We need to make sure we can safely read and write with nothing @@ -137,7 +155,7 @@ export const layer = Layer.effect(Service)( const seq = row?.seq != null ? row.seq + 1 : 0 const event = { id, seq, aggregateID: agg, data } - process(def, event, { publish }) + process(def, event, { publish, context }) }, { behavior: "immediate", @@ -242,7 +260,11 @@ export function project( return [def, func as ProjectorFunc] } -function process(def: Def, event: Event, options: { publish: boolean }) { +function process( + def: Def, + event: Event, + options: { publish: boolean; context?: PublishContext }, +) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") } @@ -281,6 +303,10 @@ function process(def: Def, event: Event, options: { Database.effect(() => { if (options?.publish) { + if (!options.context?.instance) { + throw new Error("SyncEvent.process: publish requires instance context") + } + const result = convertEvent(def.type, event.data) const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) if (result instanceof Promise) { @@ -290,9 +316,9 @@ function process(def: Def, event: Event, options: { } GlobalBus.emit("event", { - directory: Instance.directory, - project: Instance.project.id, - workspace: WorkspaceContext.workspaceID, + directory: options.context.instance.directory, + project: options.context.instance.project.id, + workspace: options.context.workspace, payload: { type: "sync", syncEvent: { From c2a97a7a6ce6733a2d4a3c6900e8d617d4dd7fe2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:45:21 -0400 Subject: [PATCH 0088/1114] refactor(file): yield instance context in watcher (#25205) --- packages/opencode/src/file/watcher.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 004679d64187..d07e14cf12f3 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,7 +10,6 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" import { Git } from "@/git" -import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Config } from "@/config/config" import { FileIgnore } from "./ignore" @@ -76,25 +75,27 @@ export const layer = Layer.effect( function* () { if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return - log.info("init", { directory: Instance.directory }) + const ctx = yield* InstanceState.context + + log.info("init", { directory: ctx.directory }) const backend = getBackend() if (!backend) { - log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform }) return } const w = watcher() if (!w) return - log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) + log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend }) const subs: ParcelWatcher.AsyncSubscription[] = [] yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), ) - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { + const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => { if (err) return for (const evt of evts) { if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" }) @@ -122,19 +123,18 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(Instance.directory, [ + yield* subscribe(ctx.directory, [ ...FileIgnore.PATTERNS, ...cfgIgnores, - ...protecteds(Instance.directory), + ...protecteds(ctx.directory), ]) } - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { const result = yield* git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, + cwd: ctx.worktree, }) - const vcsDir = - result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", From 5984d917dc87009c34fffebd6af09e2dd079f602 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 21:45:48 -0400 Subject: [PATCH 0089/1114] refactor(session): yield instance context in system prompt (#25207) --- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/session/system.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 58edffe3f9bc..fb822ff17e8b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1443,7 +1443,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const [skills, env, instructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), - Effect.sync(() => sys.environment(model)), + sys.environment(model), instruction.system().pipe(Effect.orDie), MessageV2.toModelMessagesEffect(msgs, model), ]) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 9099f2d1742b..06c71fa7dbdd 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" -import { Instance } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_DEFAULT from "./prompt/default.txt" @@ -33,7 +33,7 @@ export function provider(model: Provider.Model) { } export interface Interface { - readonly environment: (model: Provider.Model) => string[] + readonly environment: (model: Provider.Model) => Effect.Effect readonly skills: (agent: Agent.Info) => Effect.Effect } @@ -45,22 +45,22 @@ export const layer = Layer.effect( const skill = yield* Skill.Service return Service.of({ - environment(model) { - const project = Instance.project + environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { + const ctx = yield* InstanceState.context return [ [ `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `Here is some useful information about the environment you are running in:`, ``, - ` Working directory: ${Instance.directory}`, - ` Workspace root folder: ${Instance.worktree}`, - ` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`, + ` Working directory: ${ctx.directory}`, + ` Workspace root folder: ${ctx.worktree}`, + ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, ` Today's date: ${new Date().toDateString()}`, ``, ].join("\n"), ] - }, + }), skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { if (Permission.disabled(["skill"], agent.permission).has("skill")) return From 6434918794d4cd3663519db6c71006f1f1042dee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 01:46:55 +0000 Subject: [PATCH 0090/1114] chore: generate --- packages/opencode/src/file/watcher.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index d07e14cf12f3..b68c3a335607 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,11 +123,7 @@ 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* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) } if (ctx.project.vcs === "git") { From 3544ea02444bcbe1c93bb4633e6cbd69b0ff9606 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:25:52 -0400 Subject: [PATCH 0091/1114] refactor(httpapi): drop session prompt bridge (#25210) --- .../instance/httpapi/handlers/session.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) 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 e08e09495ae7..324d75730f8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,5 +1,5 @@ import * as InstanceState from "@/effect/instance-state" -import { EffectBridge } from "@/effect/bridge" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { Command } from "@/command" @@ -259,15 +259,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const bridge = yield* EffectBridge.make() + const instance = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID return HttpServerResponse.stream( Stream.fromEffect( - bridge.run( - promptSvc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }), - ), + promptSvc.prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), ).pipe( Stream.map((message) => JSON.stringify(message)), Stream.encodeText, @@ -280,10 +279,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const bridge = yield* EffectBridge.make() - yield* Effect.sync(() => { - bridge.fork( + const instance = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + yield* Effect.sync(() => + Effect.runFork( promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) @@ -294,8 +296,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }), ), ), - ) - }) + ), + ) return HttpApiSchema.NoContent.make() }) From bce4def2db91e296749bac94fbd171e23d193a5c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 02:26:56 +0000 Subject: [PATCH 0092/1114] chore: generate --- .../server/routes/instance/httpapi/handlers/session.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 324d75730f8e..384550d1ccae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -263,10 +263,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const workspace = yield* InstanceState.workspaceID return HttpServerResponse.stream( Stream.fromEffect( - promptSvc.prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), + promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), ).pipe( Stream.map((message) => JSON.stringify(message)), Stream.encodeText, From 5ba68a28c02a95e8359deefa9ee2806f84169e40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:33:02 -0400 Subject: [PATCH 0093/1114] refactor(httpapi): scope async prompt fiber (#25213) --- .../instance/httpapi/handlers/session.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) 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 384550d1ccae..cd8b5e11c28c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -19,7 +19,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 } from "effect" +import { Cause, Effect, 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" @@ -61,6 +61,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service const bus = yield* Bus.Service + const scope = yield* Scope.Scope const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { const instance = yield* InstanceState.context @@ -281,24 +282,17 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - const instance = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - yield* Effect.sync(() => - Effect.runFork( - promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( - Effect.provideService(InstanceRef, instance), - Effect.provideService(WorkspaceRef, workspace), - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) - yield* bus.publish(Session.Event.Error, { - sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), - }) - }), - ), - ), + yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), + }) + }), ), + Effect.forkIn(scope, { startImmediately: true }), ) return HttpApiSchema.NoContent.make() }) From 4c70ea28d2a44941ea65729863d0fa6e965321ce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:33:39 -0400 Subject: [PATCH 0094/1114] fix(tui): scope Zed editor context to containing workspaces (#25211) --- .../src/cli/cmd/tui/context/editor-zed.ts | 13 ++- .../test/cli/tui/editor-context-zed.test.ts | 88 ++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 5b7bf1cf4a31..6805f0b66650 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -189,13 +189,20 @@ export function resolveZedDbPath() { path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"), ].filter((item): item is string => Boolean(item)) - return candidates.find((item) => Filesystem.stat(item)?.isFile()) + return candidates.find((item) => isFile(item)) +} + +function isFile(item: string) { + try { + return Filesystem.stat(item)?.isFile() === true + } catch { + return false + } } function scoreZedWorkspace(workspacePaths: string | null, cwd: string) { return zedWorkspacePaths(workspacePaths).reduce((score, item) => { - if (pathContains(item, cwd)) return Math.max(score, 2) - if (pathContains(cwd, item)) return Math.max(score, 1) + if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length) return score }, 0) } diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 9a9bca8c5e3c..0287b0910ff0 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -1,7 +1,9 @@ import { Database } from "bun:sqlite" +import { mkdir, symlink } from "node:fs/promises" +import os from "node:os" import path from "node:path" -import { expect, test } from "bun:test" -import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" +import { expect, spyOn, test } from "bun:test" +import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" import { tmpdir } from "../../fixture/fixture" type ZedFixtureOptions = { @@ -66,6 +68,23 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () => }) }) +test("resolveZedDbPath skips candidates that cannot be stated", async () => { + await using tmp = await tmpdir() + const loop = path.join(tmp.path, "loop") + await symlink(loop, loop) + const home = spyOn(os, "homedir").mockImplementation(() => tmp.path) + const previous = process.env.OPENCODE_ZED_DB + process.env.OPENCODE_ZED_DB = loop + + try { + expect(resolveZedDbPath()).toBeUndefined() + } finally { + if (previous === undefined) delete process.env.OPENCODE_ZED_DB + else process.env.OPENCODE_ZED_DB = previous + home.mockRestore() + } +}) + test("resolveZedSelection returns active editor selection", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path) @@ -251,6 +270,71 @@ test("resolveZedSelection returns empty when no workspace matches", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) }) +test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + + expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection prefers the most specific containing Zed workspace", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + const child = path.join(tmp.path, "packages") + const childFile = path.join(child, "child.ts") + await mkdir(child, { recursive: true }) + await Bun.write(childFile, "child") + + const db = new Database(fixture.dbPath) + db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"]) + db.run("insert into panes values (2, 2, 1)") + db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"]) + db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"]) + db.run("insert into editor_selections values (2, 2, 0, 5)") + db.close() + + expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({ + type: "selection", + selection: { + filePath: childFile, + source: "zed", + ranges: [ + { + text: "child", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 6 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "effect-lab") + await mkdir(child, { recursive: true }) + const fixture = await writeZedFixture(child) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) +}) + test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false }) From 3c24d22d42b2791c967b571db2c6e77e68ab38c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:38:32 -0400 Subject: [PATCH 0095/1114] fix(httpapi): omit absent optional response fields (#25214) --- packages/opencode/src/project/project.ts | 20 ++--- packages/opencode/src/provider/auth.ts | 40 ++++----- packages/opencode/src/provider/provider.ts | 12 +-- .../test/server/httpapi-json-parity.test.ts | 81 +++++++++++++++++++ 4 files changed, 118 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 86208a60cd2e..f30d2e90c708 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) @@ -24,13 +24,13 @@ const log = Log.create({ service: "project" }) const ProjectVcs = Schema.Literal("git") const ProjectIcon = Schema.Struct({ - url: Schema.optional(Schema.String), - override: Schema.optional(Schema.String), - color: Schema.optional(Schema.String), + url: optionalOmitUndefined(Schema.String), + override: optionalOmitUndefined(Schema.String), + color: optionalOmitUndefined(Schema.String), }) const ProjectCommands = Schema.Struct({ - start: Schema.optional( + start: optionalOmitUndefined( Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }), ), }) @@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({ const ProjectTime = Schema.Struct({ created: NonNegativeInt, updated: NonNegativeInt, - initialized: Schema.optional(NonNegativeInt), + initialized: optionalOmitUndefined(NonNegativeInt), }) export const Info = Schema.Struct({ id: ProjectID, worktree: Schema.String, - vcs: Schema.optional(ProjectVcs), - name: Schema.optional(Schema.String), - icon: Schema.optional(ProjectIcon), - commands: Schema.optional(ProjectCommands), + vcs: optionalOmitUndefined(ProjectVcs), + name: optionalOmitUndefined(Schema.String), + icon: optionalOmitUndefined(ProjectIcon), + commands: optionalOmitUndefined(ProjectCommands), time: ProjectTime, sandboxes: Schema.Array(Schema.String), }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 6cbfcf1be2bf..9b2ca33c3192 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -3,7 +3,7 @@ import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { zod } from "@/util/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" @@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, - placeholder: Schema.optional(Schema.String), - when: Schema.optional(When), + placeholder: optionalOmitUndefined(Schema.String), + when: optionalOmitUndefined(When), }) const SelectOption = Schema.Struct({ label: Schema.String, value: Schema.String, - hint: Schema.optional(Schema.String), + hint: optionalOmitUndefined(Schema.String), }) const SelectPrompt = Schema.Struct({ @@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({ key: Schema.String, message: Schema.String, options: Schema.Array(SelectOption), - when: Schema.optional(When), + when: optionalOmitUndefined(When), }) const Prompt = Schema.Union([TextPrompt, SelectPrompt]) @@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt]) export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, - prompts: Schema.optional(Schema.Array(Prompt)), + prompts: optionalOmitUndefined(Schema.Array(Prompt)), }) { static readonly zod = zod(this) } @@ -135,23 +135,25 @@ export const layer: Layer.Layer = item.methods.map((method) => ({ type: method.type, label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { + ...(method.prompts && { + prompts: method.prompts.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + ...(prompt.when && { when: prompt.when }), + } + } return { - type: "select" as const, + type: "text" as const, key: prompt.key, message: prompt.message, - options: prompt.options, - when: prompt.when, + ...(prompt.placeholder && { placeholder: prompt.placeholder }), + ...(prompt.when && { when: prompt.when }), } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } + }), }), })), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 24b599db08fd..7d9806d1391e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@/util/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, - experimentalOver200K: Schema.optional( + experimentalOver200K: optionalOmitUndefined( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, @@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({ const ProviderLimit = Schema.Struct({ context: Schema.Finite, - input: Schema.optional(Schema.Finite), + input: optionalOmitUndefined(Schema.Finite), output: Schema.Finite, }) @@ -895,7 +895,7 @@ export const Model = Schema.Struct({ providerID: ProviderID, api: ProviderApiInfo, name: Schema.String, - family: Schema.optional(Schema.String), + family: optionalOmitUndefined(Schema.String), capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, @@ -903,7 +903,7 @@ export const Model = Schema.Struct({ options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, - variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), + variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), }) .annotate({ identifier: "Model" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -914,7 +914,7 @@ export const Info = Schema.Struct({ name: Schema.String, source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: Schema.optional(Schema.String), + key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), }) diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index b88a032f5d7c..645e924c6079 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -5,6 +5,10 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" +import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" @@ -89,6 +93,83 @@ afterEach(async () => { }) describe("HttpApi JSON parity", () => { + it.live( + "matches legacy JSON shape for safe GET endpoints", + withTmp( + { + git: true, + config: { + formatter: false, + lsp: false, + mcp: { + demo: { + type: "local", + command: ["echo", "demo"], + enabled: false, + }, + }, + }, + }, + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) + + const headers = { "x-opencode-directory": tmp.path } + const legacy = app(false) + const httpapi = app(true) + + yield* Effect.forEach( + [ + { label: "global.health", path: GlobalPaths.health, headers: {} }, + { label: "instance.path", path: InstancePaths.path, headers }, + { label: "instance.vcs", path: InstancePaths.vcs, headers }, + { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, + { label: "instance.command", path: InstancePaths.command, headers }, + { label: "instance.agent", path: InstancePaths.agent, headers }, + { label: "instance.skill", path: InstancePaths.skill, headers }, + { label: "instance.lsp", path: InstancePaths.lsp, headers }, + { label: "instance.formatter", path: InstancePaths.formatter, headers }, + { label: "config.get", path: "/config", headers }, + { label: "config.providers", path: "/config/providers", headers }, + { label: "project.list", path: "/project", headers }, + { label: "project.current", path: "/project/current", headers }, + { label: "provider.list", path: "/provider", headers }, + { label: "provider.auth", path: "/provider/auth", headers }, + { label: "mcp.status", path: McpPaths.status, headers }, + { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, + { + label: "file.content", + path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, + headers, + }, + { label: "file.status", path: FilePaths.status, headers }, + { + label: "find.file", + path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers, + }, + { + label: "find.text", + path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, + headers, + }, + { + label: "find.symbol", + path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, + headers, + }, + { label: "experimental.console", path: ExperimentalPaths.console, headers }, + { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, + { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, + { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, + ], + (input) => expectJsonParity({ ...input, legacy, httpapi }), + { concurrency: 1 }, + ) + }), + ), + ) + it.live( "matches legacy JSON shape for session read endpoints", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => From 8b56d1712f26901b109db300a84002f8d71b3ea5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 23:00:59 -0400 Subject: [PATCH 0096/1114] refactor(session): pass project to list (#25215) --- packages/opencode/src/cli/cmd/export.ts | 5 +- packages/opencode/src/cli/cmd/session.ts | 4 +- .../instance/httpapi/handlers/session.ts | 24 ++++----- .../src/server/routes/instance/session.ts | 28 ++++++----- packages/opencode/src/session/session.ts | 49 +++++++++++-------- .../opencode/test/server/session-list.test.ts | 33 ++++++++----- 6 files changed, 79 insertions(+), 64 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 4f19c3c4d570..62ba20e2ca67 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -245,10 +245,7 @@ export const ExportCommand = cmd({ output: process.stderr, }) - const sessions = [] - for await (const session of Session.list()) { - sessions.push(session) - } + const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) if (sessions.length === 0) { prompts.log.error("No sessions found", { diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index ae9f7c884453..52a3d7204e04 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -91,7 +91,9 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + const sessions = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })), + ) if (sessions.length === 0) { return 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 cd8b5e11c28c..8cc969f483f5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -5,7 +5,6 @@ import { Bus } from "@/bus" import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Instance } from "@/project/instance" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -64,20 +63,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const scope = yield* Scope.Scope const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { - const instance = yield* InstanceState.context - return Instance.restore(instance, () => - Array.from( - Session.list({ - directory: ctx.query.scope === "project" ? undefined : ctx.query.directory, - scope: ctx.query.scope, - path: ctx.query.path, - roots: ctx.query.roots, - start: ctx.query.start, - search: ctx.query.search, - limit: ctx.query.limit, - }), - ), - ) + return yield* session.list({ + directory: ctx.query.scope === "project" ? undefined : ctx.query.directory, + scope: ctx.query.scope, + path: ctx.query.path, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + limit: ctx.query.limit, + }) }) const status = Effect.fn("SessionHttpApi.status")(function* () { diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 410d8bba0c97..a16a92f927a6 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -78,18 +78,22 @@ export const SessionRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query") - const sessions: Session.Info[] = [] - for await (const session of Session.list({ - directory: query.scope === "project" ? undefined : query.directory, - path: query.path, - roots: queryBoolean(query.roots), - start: query.start, - search: query.search, - limit: query.limit, - })) { - sessions.push(session) - } - return c.json(sessions) + return c.json( + await runRequest( + "SessionRoutes.list", + c, + Session.Service.use((svc) => + svc.list({ + directory: query.scope === "project" ? undefined : query.directory, + path: query.path, + roots: queryBoolean(query.roots), + start: query.start, + search: query.search, + limit: query.limit, + }), + ), + ), + ) }, ) .get( diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 7e6016b87fe8..2ff7842bdbca 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -26,7 +26,7 @@ import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" -import { Instance, type InstanceContext } from "../project/instance" +import type { InstanceContext } from "../project/instance" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" @@ -234,6 +234,16 @@ export const MessagesInput = Schema.Struct({ sessionID: SessionID, limit: Schema.optional(NonNegativeInt), }).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ListInput = { + directory?: string + scope?: "project" + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + limit?: number +} const CreatedEventSchema = Schema.Struct({ sessionID: SessionID, @@ -390,6 +400,7 @@ export class BusyError extends Error { } export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID title?: string @@ -498,6 +509,11 @@ export const layer: Layer.Layer d @@ -731,6 +747,7 @@ export const layer: Layer.Layer db diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index cbdda6b42648..e2f92c20f6e1 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -23,6 +23,9 @@ const svc = { create(input?: SessionNs.CreateInput) { return run(SessionNs.Service.use((svc) => svc.create(input))) }, + list(input?: SessionNs.ListInput) { + return run(SessionNs.Service.use((svc) => svc.list(input))) + }, } afterEach(async () => { @@ -55,7 +58,7 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const ids = [...svc.list()].map((s) => s.id) + const ids = (await svc.list()).map((s) => s.id) expect(ids).toContain(root.id) expect(ids).toContain(parent.id) expect(ids).toContain(current.id) @@ -88,7 +91,7 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id) + const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id) expect(ids).not.toContain(root.id) expect(ids).not.toContain(parent.id) expect(ids).toContain(current.id) @@ -123,9 +126,12 @@ describe("session.list", () => { fn: async () => svc.create({ title: "sibling" }), }) - const pathIDs = [ - ...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }), - ].map((s) => s.id) + const pathIDs = ( + await svc.list({ + directory: path.join(tmp.path, "packages", "app"), + path: "packages/opencode/src", + }) + ).map((s) => s.id) expect(pathIDs).not.toContain(parent.id) expect(pathIDs).toContain(current.id) expect(pathIDs).toContain(deeper.id) @@ -155,9 +161,12 @@ describe("session.list", () => { Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) - const pathIDs = [ - ...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }), - ].map((s) => s.id) + const pathIDs = ( + await svc.list({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + path: "packages/opencode/src", + }) + ).map((s) => s.id) expect(pathIDs).toContain(current.id) expect(pathIDs).not.toContain(sibling.id) }, @@ -172,7 +181,7 @@ describe("session.list", () => { const root = await svc.create({ title: "root-session" }) const child = await svc.create({ title: "child-session", parentID: root.id }) - const sessions = [...svc.list({ roots: true })] + const sessions = await svc.list({ roots: true }) const ids = sessions.map((s) => s.id) expect(ids).toContain(root.id) @@ -189,7 +198,7 @@ describe("session.list", () => { await svc.create({ title: "new-session" }) const futureStart = Date.now() + 86400000 - const sessions = [...svc.list({ start: futureStart })] + const sessions = await svc.list({ start: futureStart }) expect(sessions.length).toBe(0) }, }) @@ -203,7 +212,7 @@ describe("session.list", () => { await svc.create({ title: "unique-search-term-abc" }) await svc.create({ title: "other-session-xyz" }) - const sessions = [...svc.list({ search: "unique-search" })] + const sessions = await svc.list({ search: "unique-search" }) const titles = sessions.map((s) => s.title) expect(titles).toContain("unique-search-term-abc") @@ -221,7 +230,7 @@ describe("session.list", () => { await svc.create({ title: "session-2" }) await svc.create({ title: "session-3" }) - const sessions = [...svc.list({ limit: 2 })] + const sessions = await svc.list({ limit: 2 }) expect(sessions.length).toBe(2) }, }) From dd3aa9673058bc79a7bf6f10d476345dbd75a78c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 23:01:11 -0400 Subject: [PATCH 0097/1114] test(httpapi): cover more safe GET parity (#25217) --- packages/opencode/test/server/httpapi-json-parity.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 645e924c6079..0465b1cf6f37 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -9,6 +9,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" @@ -121,6 +122,7 @@ describe("HttpApi JSON parity", () => { yield* Effect.forEach( [ { label: "global.health", path: GlobalPaths.health, headers: {} }, + { label: "global.config", path: GlobalPaths.config, headers: {} }, { label: "instance.path", path: InstancePaths.path, headers }, { label: "instance.vcs", path: InstancePaths.vcs, headers }, { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, @@ -135,7 +137,11 @@ describe("HttpApi JSON parity", () => { { label: "project.current", path: "/project/current", headers }, { label: "provider.list", path: "/provider", headers }, { label: "provider.auth", path: "/provider/auth", headers }, + { label: "permission.list", path: "/permission", headers }, + { label: "question.list", path: "/question", headers }, { label: "mcp.status", path: McpPaths.status, headers }, + { label: "pty.shells", path: PtyPaths.shells, headers }, + { label: "pty.list", path: PtyPaths.list, headers }, { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, { label: "file.content", @@ -162,6 +168,7 @@ describe("HttpApi JSON parity", () => { { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, + { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, ], (input) => expectJsonParity({ ...input, legacy, httpapi }), { concurrency: 1 }, From 8b56d77ea13c34ab1aa97e9c26cddf2ee75cf494 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 03:02:15 +0000 Subject: [PATCH 0098/1114] chore: generate --- packages/opencode/src/session/session.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2ff7842bdbca..5593efc9714b 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -779,9 +779,11 @@ export const defaultLayer = layer.pipe( Layer.provide(SyncEvent.defaultLayer), ) -function* listByProject(input: ListInput & { - projectID: ProjectID -}) { +function* listByProject( + input: ListInput & { + projectID: ProjectID + }, +) { const conditions = [eq(SessionTable.project_id, input.projectID)] if (input.workspaceID) { From ff55a40749fdb749e8519cca65c0dcf8e330349e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Apr 2026 23:20:20 -0400 Subject: [PATCH 0099/1114] core: remove @effect/language-service plugin and optimize hot path type performance - Removed @effect/language-service from both packages/core and packages/opencode tsconfig files and dependencies - Wrapped mergeDeep calls in config loading and LLM streaming to avoid expensive remeda conditional merge type instantiations in hot paths - Narrowed Drizzle migrate() overload signature to avoid expensive variance checks during database initialization These changes reduce TypeScript type-checking overhead and improve startup and runtime performance for config loading, LLM streaming, and database migrations. --- bun.lock | 3 --- packages/core/tsconfig.json | 9 +-------- packages/opencode/package.json | 2 -- packages/opencode/src/config/config.ts | 21 ++++++++++++--------- packages/opencode/src/session/llm.ts | 14 ++++++++------ packages/opencode/src/storage/db.ts | 9 ++++++++- packages/opencode/tsconfig.json | 9 +-------- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 64c372661f61..093bb880a480 100644 --- a/bun.lock +++ b/bun.lock @@ -456,7 +456,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -1069,8 +1068,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="], diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d7745d7554c7..fe5c4d217b2e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,13 +2,6 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "noUncheckedIndexedAccess": false, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + "noUncheckedIndexedAccess": false } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 425ddea77acb..cf2e574f51b1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -6,7 +6,6 @@ "license": "MIT", "private": true, "scripts": { - "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", @@ -42,7 +41,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@opencode-ai/core": "workspace:*", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c79e950e909d..44841fe6fcd4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -3,7 +3,7 @@ import path from "path" import { pathToFileURL } from "url" import os from "os" import z from "zod" -import { mergeDeep, pipe } from "remeda" +import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/core/util/error" @@ -47,8 +47,13 @@ import { Npm } from "@opencode-ai/core/npm" const log = Log.create({ service: "config" }) // Custom merge function that concatenates array fields instead of replacing them +// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. +function mergeConfig(target: Info, source: Info): Info { + return mergeDeep(target, source) as Info +} + function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeDeep(target, source) + const merged = mergeConfig(target, source) if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } @@ -387,12 +392,10 @@ export const layer = Layer.effect( }) const loadGlobal = Effect.fnUntraced(function* () { - let result: Info = pipe( - {}, - mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))), - mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))), - ) + let result: Info = {} + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) + result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -402,7 +405,7 @@ export const layer = Layer.effect( const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" - result = mergeDeep(result, rest) + result = mergeConfig(result, rest) await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fsNode.unlink(legacy) }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 58677debc00e..69b0b27c3a2a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -3,7 +3,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" -import { mergeDeep, pipe } from "remeda" +import { mergeDeep } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" @@ -29,6 +29,10 @@ const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX type Result = Awaited> +// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. +const mergeOptions = (target: Record, source: Record | undefined): Record => + mergeDeep(target, source ?? {}) as Record + export type StreamInput = { user: MessageV2.User sessionID: string @@ -134,11 +138,9 @@ const live: Layer.Layer< sessionID: input.sessionID, providerOptions: item.options, }) - const options: Record = pipe( - base, - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - mergeDeep(variant), + const options = mergeOptions( + mergeOptions(mergeOptions(base, input.model.options), input.agent.options), + variant, ) if (isOpenaiOauth) { options.instructions = system.join("\n") diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 95bb568bd3bd..de4683b751fc 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -48,6 +48,13 @@ type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number; name: string }[] +// Drizzle's migrate overloads trigger expensive variance checks here; narrow to the journal overload we actually use. +const migrateFromJournal = migrate as unknown as (db: SQLiteBunDatabase, entries: Journal) => void + +function applyMigrations(db: SQLiteBunDatabase, entries: Journal) { + migrateFromJournal(db, entries) +} + function time(tag: string) { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag) if (!match) return 0 @@ -108,7 +115,7 @@ export const Client = lazy(() => { item.sql = "select 1;" } } - migrate(db, entries) + applyMigrations(db, entries) } return db diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json index 5cb51012ae31..f09fca687835 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -12,13 +12,6 @@ "@/*": ["./src/*"], "@tui/*": ["./src/cli/cmd/tui/*"], "@test/*": ["./test/*"] - }, - "plugins": [ - { - "name": "@effect/language-service", - "transform": "@effect/language-service/transform", - "namespaceImportPackages": ["effect", "@effect/*"] - } - ] + } } } From 6bd91c68e81755e6f280ccf4b3122b37e792a00e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 03:22:36 +0000 Subject: [PATCH 0100/1114] chore: generate --- packages/opencode/src/session/llm.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 69b0b27c3a2a..e76583f2d347 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -138,10 +138,7 @@ const live: Layer.Layer< sessionID: input.sessionID, providerOptions: item.options, }) - const options = mergeOptions( - mergeOptions(mergeOptions(base, input.model.options), input.agent.options), - variant, - ) + const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) if (isOpenaiOauth) { options.instructions = system.join("\n") } From 461e7345b39618e657406948f38226facaf922be Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 03:32:55 +0000 Subject: [PATCH 0101/1114] 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 3691154c4705..d9169d95c381 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-cBfg4pJ4mjsfS4MFFASBaZZykArgIoeo/3woOcSGy1U=", - "aarch64-linux": "sha256-Q6cqUwfqbscdrPW0uHcfshhQINjJi0HiyURMSdOOCf4=", - "aarch64-darwin": "sha256-1AtfsD1D9YxWSEsecPJF9XsvsxsWTtVtkP5l6UW43og=", - "x86_64-darwin": "sha256-YS5/8YTf9LymAUbjXVrGDfxtKVJrpZbPnnCtsGHSHoU=" + "x86_64-linux": "sha256-QthnHaV7hTxvxEzQpxI7HWjb2n1ZMTYviA7DDdAzJwk=", + "aarch64-linux": "sha256-l6/2wVmNBBtCI0ovhfhyq3ZSebj6qZFWXptYqUy2Rh8=", + "aarch64-darwin": "sha256-upNQqcwa/b/XPyQFTMOkp6S5QvILg5Y3LnDCmEe9iqA=", + "x86_64-darwin": "sha256-/M91h4YUTsBveDhwtl5mTkQaRE+92WZmzs2p4D+RuBk=" } } From 33f7f593eeba84de34c52779a42b24b4edfa652a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:45:41 -0500 Subject: [PATCH 0102/1114] fix: tui list jank issue (#25219) --- .../opencode/src/cli/cmd/tui/component/dialog-provider.tsx | 2 +- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 2 +- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index ebc28847f0d8..d6cbda413317 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -51,7 +51,7 @@ export function createDialogProviderOptions() { }[provider.id], footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - gutter: connected && onboarded() ? : undefined, + gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return 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 72d60767bb9a..04c6b9945c8f 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 @@ -168,7 +168,7 @@ export function DialogSessionList() { value: x.id, category, footer, - gutter: isWorking ? : undefined, + gutter: isWorking ? () => : undefined, } }) }) 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 b6c937f4115c..4d68c4430891 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,7 +42,7 @@ export interface DialogSelectOption { categoryView?: JSX.Element disabled?: boolean bg?: RGBA - gutter?: JSX.Element + gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -407,7 +407,7 @@ function Option(props: { active?: boolean current?: boolean footer?: JSX.Element | string - gutter?: JSX.Element + gutter?: () => JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() @@ -422,7 +422,7 @@ function Option(props: { - {props.gutter} + {props.gutter?.()} Date: Thu, 30 Apr 2026 23:47:15 -0400 Subject: [PATCH 0103/1114] Preapprove agent tmp directory access (#25226) --- packages/core/src/global.ts | 5 +++++ packages/opencode/src/agent/agent.ts | 6 +++++- packages/opencode/src/tool/bash.ts | 2 ++ packages/opencode/src/tool/bash.txt | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 42e0f1030a98..1acc3f47f181 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -11,6 +11,7 @@ const data = path.join(xdgData!, app) const cache = path.join(xdgCache!, app) const config = path.join(xdgConfig!, app) const state = path.join(xdgState!, app) +const tmp = path.join(os.tmpdir(), app) const paths = { get home() { @@ -22,6 +23,7 @@ const paths = { cache, config, state, + tmp, } export const Path = paths @@ -32,6 +34,7 @@ await Promise.all([ fs.mkdir(Path.data, { recursive: true }), fs.mkdir(Path.config, { recursive: true }), fs.mkdir(Path.state, { recursive: true }), + fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), ]) @@ -44,6 +47,7 @@ export interface Interface { readonly cache: string readonly config: string readonly state: string + readonly tmp: string readonly bin: string readonly log: string } @@ -55,6 +59,7 @@ export function make(input: Partial = {}): Interface { cache: Path.cache, config: Flag.OPENCODE_CONFIG_DIR ?? Path.config, state: Path.state, + tmp: Path.tmp, bin: Path.bin, log: Path.log, ...input, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2a090b0eedef..b38b0cc5dd4d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -81,7 +81,11 @@ export const layer = Layer.effect( Effect.fn("Agent.state")(function* (ctx) { const cfg = yield* config.get() const skillDirs = yield* skill.dirs() - const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))] + const whitelistedDirs = [ + Truncate.GLOB, + path.join(Global.Path.tmp, "*"), + ...skillDirs.map((dir) => path.join(dir, "*")), + ] const defaults = Permission.fromConfig({ "*": "allow", diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index c50b259f7a40..fe3e45d66fdc 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -14,6 +14,7 @@ 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 { BashArity } from "@/permission/arity" @@ -587,6 +588,7 @@ export const BashTool = Tool.define( return { description: DESCRIPTION.replaceAll("${directory}", instance.directory) + .replaceAll("${tmp}", Global.Path.tmp) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) .replaceAll("${chaining}", chain) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index c2fe873791fa..04e935fe74ce 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -4,6 +4,8 @@ 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. +Use `${tmp}` for temporary work outside the workspace. This directory 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: From 3615d8e226a5dae909139d306e2a684a92680ccf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Apr 2026 23:48:48 -0400 Subject: [PATCH 0104/1114] core: clarify that temp directory already exists for AI agents The bash tool description now explicitly states that the temp directory has already been created and exists, preventing agents from unnecessarily trying to create it before use. --- packages/opencode/src/tool/bash.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 04e935fe74ce..a131ed7e6339 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -4,7 +4,7 @@ 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. -Use `${tmp}` for temporary work outside the workspace. This directory is pre-approved for external directory access. +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. From c68c33d4fea5b34bf2ca8529b4f54fdb58d07701 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:49:32 -0500 Subject: [PATCH 0105/1114] docs: remove deprecated modes.mdx pages (#25227) --- packages/web/src/content/docs/ar/modes.mdx | 329 ------------------ packages/web/src/content/docs/bs/modes.mdx | 314 ----------------- packages/web/src/content/docs/da/modes.mdx | 329 ------------------ packages/web/src/content/docs/de/modes.mdx | 329 ------------------ packages/web/src/content/docs/es/modes.mdx | 329 ------------------ packages/web/src/content/docs/fr/modes.mdx | 327 ----------------- packages/web/src/content/docs/it/modes.mdx | 328 ----------------- packages/web/src/content/docs/ja/modes.mdx | 327 ----------------- packages/web/src/content/docs/ko/modes.mdx | 327 ----------------- packages/web/src/content/docs/modes.mdx | 329 ------------------ packages/web/src/content/docs/nb/modes.mdx | 328 ----------------- packages/web/src/content/docs/pl/modes.mdx | 329 ------------------ packages/web/src/content/docs/pt-br/modes.mdx | 326 ----------------- packages/web/src/content/docs/ru/modes.mdx | 329 ------------------ packages/web/src/content/docs/th/modes.mdx | 329 ------------------ packages/web/src/content/docs/tr/modes.mdx | 329 ------------------ packages/web/src/content/docs/zh-cn/modes.mdx | 326 ----------------- packages/web/src/content/docs/zh-tw/modes.mdx | 326 ----------------- 18 files changed, 5890 deletions(-) delete mode 100644 packages/web/src/content/docs/ar/modes.mdx delete mode 100644 packages/web/src/content/docs/bs/modes.mdx delete mode 100644 packages/web/src/content/docs/da/modes.mdx delete mode 100644 packages/web/src/content/docs/de/modes.mdx delete mode 100644 packages/web/src/content/docs/es/modes.mdx delete mode 100644 packages/web/src/content/docs/fr/modes.mdx delete mode 100644 packages/web/src/content/docs/it/modes.mdx delete mode 100644 packages/web/src/content/docs/ja/modes.mdx delete mode 100644 packages/web/src/content/docs/ko/modes.mdx delete mode 100644 packages/web/src/content/docs/modes.mdx delete mode 100644 packages/web/src/content/docs/nb/modes.mdx delete mode 100644 packages/web/src/content/docs/pl/modes.mdx delete mode 100644 packages/web/src/content/docs/pt-br/modes.mdx delete mode 100644 packages/web/src/content/docs/ru/modes.mdx delete mode 100644 packages/web/src/content/docs/th/modes.mdx delete mode 100644 packages/web/src/content/docs/tr/modes.mdx delete mode 100644 packages/web/src/content/docs/zh-cn/modes.mdx delete mode 100644 packages/web/src/content/docs/zh-tw/modes.mdx diff --git a/packages/web/src/content/docs/ar/modes.mdx b/packages/web/src/content/docs/ar/modes.mdx deleted file mode 100644 index ed17670a5558..000000000000 --- a/packages/web/src/content/docs/ar/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: الأوضاع -description: أوضاع مختلفة لحالات استخدام مختلفة. ---- - -:::caution -يتم الآن ضبط الأوضاع عبر خيار `agent` في إعدادات opencode. أصبح خيار -`mode` مُهمَلًا الآن. [اعرف المزيد](/docs/agents). -::: - -تتيح لك الأوضاع في opencode تخصيص السلوك والأدوات والمطالبات لحالات استخدام مختلفة. - -يأتي مع وضعين مدمجين: **build** و **plan**. يمكنك تخصيصهما أو إعداد -أوضاعك الخاصة عبر إعدادات opencode. - -يمكنك التبديل بين الأوضاع أثناء الجلسة أو إعدادها في ملف الإعدادات لديك. - ---- - -## الأوضاع المدمجة - -يأتي opencode مع وضعين مدمجين. - ---- - -### Build - -وضع Build هو الوضع **الافتراضي** مع تفعيل جميع الأدوات. هذا هو الوضع القياسي لأعمال التطوير عندما تحتاج إلى وصول كامل لعمليات الملفات وأوامر النظام. - ---- - -### Plan - -وضع مقيَّد مُصمَّم للتخطيط والتحليل. في وضع plan، تكون الأدوات التالية مُعطَّلة افتراضيًا: - -- `write` - لا يمكن إنشاء ملفات جديدة -- `edit` - لا يمكن تعديل الملفات الموجودة، باستثناء الملفات الموجودة في `.opencode/plans/*.md` لتفصيل الخطة نفسها -- `patch` - لا يمكن تطبيق التصحيحات -- `bash` - لا يمكن تنفيذ أوامر shell - -يكون هذا الوضع مفيدًا عندما تريد من الذكاء الاصطناعي تحليل الشيفرة، أو اقتراح تغييرات، أو إنشاء خطط دون إجراء أي تعديلات فعلية على قاعدة الشيفرة لديك. - ---- - -## التبديل - -يمكنك التبديل بين الأوضاع أثناء الجلسة باستخدام مفتاح _Tab_، أو اختصار `switch_mode` الذي قمت بإعداده. - -انظر أيضًا: [Formatters](/docs/formatters) لمعلومات حول إعدادات تنسيق الشيفرة. - ---- - -## الإعداد - -يمكنك تخصيص الأوضاع المدمجة أو إنشاء أوضاعك الخاصة عبر الإعدادات. يمكن إعداد الأوضاع بطريقتين: - -### إعدادات JSON - -قم بإعداد الأوضاع في ملف الإعدادات `opencode.json` لديك: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### إعدادات Markdown - -يمكنك أيضًا تعريف الأوضاع باستخدام ملفات markdown. ضعها في: - -- عالمي: `~/.config/opencode/modes/` -- للمشروع: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -يصبح اسم ملف markdown هو اسم الوضع (على سبيل المثال، `review.md` ينشئ وضعًا باسم `review`). - -لنلقِ نظرة على خيارات الإعداد هذه بمزيد من التفصيل. - ---- - -### النموذج - -استخدم إعداد `model` لتجاوز النموذج الافتراضي لهذا الوضع. يفيد ذلك عند استخدام نماذج مختلفة مُحسَّنة لمهام مختلفة؛ مثل نموذج أسرع للتخطيط ونموذج أكثر قدرة للتنفيذ. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### درجة الحرارة - -تحكَّم في العشوائية والإبداع في ردود الذكاء الاصطناعي عبر إعداد `temperature`. القيم الأقل تجعل الردود أكثر تركيزًا وحتمية، بينما تزيد القيم الأعلى الإبداع والتنوّع. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -تتراوح قيم درجة الحرارة عادةً بين 0.0 و 1.0: - -- **0.0-0.2**: ردود شديدة التركيز وحتمية، مثالية لتحليل الشيفرة والتخطيط -- **0.3-0.5**: ردود متوازنة مع قدر من الإبداع، مناسبة لمهام التطوير العامة -- **0.6-1.0**: ردود أكثر إبداعًا وتنوّعًا، مفيدة للعصف الذهني والاستكشاف - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -إذا لم يتم تحديد درجة الحرارة، يستخدم opencode القيم الافتراضية الخاصة بكل نموذج (عادةً 0 لمعظم النماذج و 0.55 لنماذج Qwen). - ---- - -### الموجّه - -حدِّد ملف موجّه نظام (system prompt) مخصص لهذا الوضع عبر إعداد `prompt`. ينبغي أن يحتوي ملف الموجّه على تعليمات مرتبطة بهدف هذا الوضع. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -هذا المسار نسبي بالنسبة لموقع ملف الإعدادات. لذلك يعمل مع -إعدادات opencode العالمية وكذلك إعدادات المشروع. - ---- - -### الأدوات - -تحكَّم في الأدوات المتاحة في هذا الوضع عبر إعداد `tools`. يمكنك تفعيل أدوات محددة أو تعطيلها بضبطها على `true` أو `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -إذا لم يتم تحديد أدوات، فستكون جميع الأدوات مفعّلة افتراضيًا. - ---- - -#### الأدوات المتاحة - -فيما يلي جميع الأدوات التي يمكن التحكم بها عبر إعدادات الوضع. - -| الأداة | الوصف | -| ----------- | -------------------------- | -| `bash` | تنفيذ أوامر shell | -| `edit` | تعديل الملفات الموجودة | -| `write` | إنشاء ملفات جديدة | -| `read` | قراءة محتويات الملفات | -| `grep` | البحث في محتويات الملفات | -| `glob` | العثور على الملفات حسب نمط | -| `patch` | تطبيق تصحيحات على الملفات | -| `todowrite` | إدارة قوائم المهام | -| `webfetch` | جلب محتوى الويب | - ---- - -## أوضاع مخصصة - -يمكنك إنشاء أوضاعك المخصصة بإضافتها إلى الإعدادات. فيما يلي أمثلة باستخدام كلا الأسلوبين: - -### باستخدام إعدادات JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### باستخدام ملفات markdown - -أنشئ ملفات الأوضاع في `.opencode/modes/` لأوضاع خاصة بالمشروع أو في `~/.config/opencode/modes/` لأوضاع عالمية: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### حالات الاستخدام - -فيما يلي بعض حالات الاستخدام الشائعة لأوضاع مختلفة. - -- **Build mode**: أعمال تطوير كاملة مع تفعيل جميع الأدوات -- **Plan mode**: التحليل والتخطيط دون إجراء تغييرات -- **Review mode**: مراجعة الشيفرة مع وصول للقراءة فقط بالإضافة إلى أدوات التوثيق -- **Debug mode**: تركيز على الاستقصاء مع تفعيل أدوات `bash` و `read` -- **Docs mode**: كتابة التوثيق مع عمليات الملفات لكن دون أوامر النظام - -قد تجد أيضًا أن نماذج مختلفة تكون أنسب لحالات استخدام مختلفة. diff --git a/packages/web/src/content/docs/bs/modes.mdx b/packages/web/src/content/docs/bs/modes.mdx deleted file mode 100644 index d5f92a9f67eb..000000000000 --- a/packages/web/src/content/docs/bs/modes.mdx +++ /dev/null @@ -1,314 +0,0 @@ ---- -title: Načini rada -description: Različiti načini za različite slučajeve upotrebe. ---- - -:::caution -Načini se sada konfiguriraju preko opcije `agent` u konfiguraciji otvorenog koda. The -`mode` opcija je sada zastarjela. [Saznajte više](/docs/agents). -::: - -Režimi u otvorenom kodu omogućavaju vam da prilagodite ponašanje, alate i upite za različite slučajeve upotrebe. -Dolazi sa dva ugrađena načina rada: **build** i **plan**. Možete prilagoditi -ove ili konfigurirajte svoje putem opencode config. -Možete se prebacivati ​​između režima tokom sesije ili ih konfigurisati u svom konfiguracionom fajlu. - ---- - -## Ugrađeni - -opencode dolazi sa dva ugrađena načina rada. - -### Build - -Build je **podrazumijevani** režim sa svim omogućenim alatima. Ovo je standardni način rada za razvoj kada vam treba pun pristup fajlovima i sistemskim komandama. - -### Plan - -Ograničeni način rada dizajniran za planiranje i analizu. U načinu plana, sljedeći alati su onemogućeni prema zadanim postavkama: - -- `write` - Ne mogu kreirati nove fajlove -- `edit` - Ne mogu modificirati postojeće fajlove, osim fajlova koji se nalaze na `.opencode/plans/*.md` radi detaljiziranja samog plana -- `patch` - Ne mogu primijeniti zakrpe -- `bash` - Ne mogu izvršiti naredbe ljuske - Ovaj način rada je koristan kada želite da AI analizira kod, predlaže promjene ili kreira planove bez ikakvih stvarnih modifikacija u vašoj bazi kodova. - ---- - -## Prebacivanje - -Možete se prebacivati ​​između načina rada tokom sesije pomoću tipke _Tab_. Ili vaše konfigurirano `switch_mode` spajanje tipki. -Vidi također: [Formatters](/docs/formatters) za informacije o konfiguraciji formatiranja koda. - ---- - -## Konfiguracija - -Možete prilagoditi ugrađene načine rada ili kreirati vlastite kroz konfiguraciju. Modovi se mogu konfigurirati na dva načina: - -### JSON konfiguracija - -Konfigurirajte načine rada u svom `opencode.json` konfiguracijskom fajlu: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown konfiguracija - -Također možete definirati načine rada koristeći markdown datoteke. Postavite ih u: - -- Globalno: `~/.config/opencode/modes/` -- Projekat: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Naziv datoteke s uštedom postaje naziv načina (npr. `review.md` kreira `review` način rada). -Pogledajmo ove opcije konfiguracije detaljno. - ---- - -### Model - -Koristite `model` konfiguraciju da nadjačate zadani model za ovaj način rada. Korisno za korištenje različitih modela optimiziranih za različite zadatke. Na primjer, brži model za planiranje, sposobniji model za implementaciju. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontrolišite slučajnost i kreativnost odgovora AI pomoću `temperature` konfiguracije. Niže vrijednosti čine odgovore fokusiranijim i determinističkim, dok veće vrijednosti povećavaju kreativnost i varijabilnost. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Vrijednosti temperature obično se kreću od 0,0 do 1,0: - -- **0,0-0,2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda -- **0,3-0,5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke -- **0,6-1,0**: kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Ako temperatura nije navedena, opencode koristi podrazumijevane postavke specifične za model (obično 0 za većinu modela i 0.55 za Qwen modele). - -### Upit - -Navedite prilagođenu sistemsku datoteku prompta za ovaj način rada s konfiguracijom `prompt`. Datoteka s promptom treba da sadrži upute specifične za svrhu načina rada. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ova putanja je relativna u odnosu na mjesto gdje se nalazi konfiguracijski fajl. Dakle, ovo radi za -i globalnu konfiguraciju otvorenog koda i konfiguraciju specifične za projekat. - ---- - -### Alati - -Kontrolirajte koji su alati dostupni u ovom načinu rada pomoću `tools` konfiguracije. Možete omogućiti ili onemogućiti određene alate tako što ćete ih postaviti na `true` ili `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Ako nijedan alat nije specificiran, svi alati su omogućeni po defaultu. - -#### Dostupni alati - -Ovdje su svi alati koji se mogu kontrolirati kroz konfiguraciju načina rada. -| Alat | Opis -|----------- | ----------------------- | -| `bash` | Izvrši naredbe ljuske | -| `edit` | Izmijenite postojeće datoteke | -| `write` | Kreirajte nove fajlove | -| `read` | Pročitajte sadržaj datoteke | -| `grep` | Pretraži sadržaj datoteke | -| `glob` | Pronađite datoteke po uzorku | -| `patch` | Primijenite zakrpe na datoteke | -| `todowrite` | Upravljanje listama zadataka | -| `webfetch` | Dohvati web sadržaj | - ---- - -## Prilagođeni načini rada - -Možete kreirati vlastite prilagođene modove tako što ćete ih dodati u konfiguraciju. Evo primjera koji koriste oba pristupa: - -### Korištenje JSON konfiguracije - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korištenje markdown fajlova - -Kreirajte fajlove načina u `.opencode/modes/` za specifične načine rada ili `~/.config/opencode/modes/` za globalne načine: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Slučajevi upotrebe - -Evo nekoliko uobičajenih slučajeva upotrebe za različite načine rada. - -- **Build mode**: Potpuni razvojni rad sa svim omogućenim alatima -- **Plan mode**: Analiza i planiranje bez izmjena -- **Review mode**: Pregled koda sa pristupom samo za čitanje plus alati za dokumentaciju -- **Debug mode**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje -- **Docs mode**: Pisanje dokumentacije sa operacijama datoteka, ali bez sistemskih naredbi - Možda ćete također otkriti da su različiti modeli dobri za različite slučajeve upotrebe. diff --git a/packages/web/src/content/docs/da/modes.mdx b/packages/web/src/content/docs/da/modes.mdx deleted file mode 100644 index a0fb87a8626a..000000000000 --- a/packages/web/src/content/docs/da/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tilstande -description: Forskellige tilstande til forskellige anvendelsestilfælde. ---- - -:::caution -Tilstande er nu konfigureret gennem indstillingen `agent` i opencode-konfigurationen. De -`mode` mulighed er nu forældet. [Learn more](/docs/agents). -::: - -Tilstande i opencode giver dig mulighed for at tilpasse adfærd, værktøjer og prompter til forskellige brugstilfælde. - -Den kommer med to indbyggede tilstande: **build** og **plan**. Du kan tilpasse -disse eller konfigurer dine egne gennem opencode-konfigurationen. - -Du kan skifte mellem tilstande under en session eller konfigurere dem i din konfigurationsfil. - ---- - -## Indbyggede - -opencode leveres med to indbyggede tilstande. - ---- - -### Byg - -Byg er **standard**-tilstanden med alle værktøjer aktiveret. Dette er standardtilstanden for udviklingsarbejde, hvor du har brug for fuld adgang til filhandlinger og systemkommandoer. - ---- - -### Plan - -En begrænset tilstand designet til planlægning og analyse. I plantilstand er følgende værktøjer deaktiveret som standard: - -- `write` - Kan ikke oprette nye filer -- `edit` - Kan ikke ændre eksisterende filer, undtagen filer placeret på `.opencode/plans/*.md` for at detaljere selve planen -- `patch` - Kan ikke anvende patches -- `bash` - Kan ikke udføre shell-kommandoer - -Denne tilstand er nyttig, når du vil have AI til at analysere kode, foreslå ændringer eller oprette planer uden at foretage egentlige ændringer af din kodebase. - ---- - -## Skift - -Du kan skifte mellem tilstande under en session ved at bruge _Tab_-tasten. Eller din konfigurerede `switch_mode` nøglebinding. - -Se også: [Formatters](/docs/formatters) for information om konfiguration af kodeformatering. - ---- - -## Konfiguration - -Du kan tilpasse de indbyggede tilstande eller oprette dine egne gennem konfiguration. Tilstande kan konfigureres på to måder: - -### JSON-konfiguration - -Konfigurer tilstande i din `opencode.json`-konfigurationsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfiguration - -Du kan også definere tilstande ved hjælp af markdown-filer. Placer dem i: - -- Globalt: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown-filnavnet bliver tilstandsnavnet (f.eks. opretter `review.md` en `review`-tilstand). - -Lad os se nærmere på disse konfigurationsmuligheder. - ---- - -### Model - -Brug `model`-konfigurationen til at tilsidesætte standardmodellen for denne tilstand. Nyttigt til brug af forskellige modeller optimeret til forskellige opgaver. For eksempel en hurtigere model til planlægning, en mere dygtig model til implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Styr tilfældigheden og kreativiteten af ​​AI's svar med `temperature`-konfigurationen. Lavere værdier gør svar mere fokuserede og deterministiske, mens højere værdier øger kreativitet og variabilitet. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturværdier varierer typisk fra 0,0 til 1,0: - -- **0.0-0.2**: Meget fokuserede og deterministiske svar, ideel til kodeanalyse og planlægning -- **0,3-0,5**: Afbalancerede svar med en vis kreativitet, god til generelle udviklingsopgaver -- **0.6-1.0**: Mere kreative og varierede svar, nyttige til brainstorming og udforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis der ikke er angivet nogen temperatur, bruger opencode modelspecifikke standarder (typisk 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angiv en brugerdefineret systempromptfil for denne tilstand med `prompt`-konfigurationen. Promptfilen skal indeholde instruktioner, der er specifikke for tilstandens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne sti er i forhold til, hvor konfigurationsfilen er placeret. Så dette virker for -både den globale opencode-konfiguration og den projektspecifikke konfiguration. - ---- - -### Værktøjer - -Kontroller, hvilke værktøjer der er tilgængelige i denne tilstand med `tools`-konfigurationen. Du kan aktivere eller deaktivere specifikke værktøjer ved at indstille dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis der ikke er angivet nogen værktøjer, er alle værktøjer aktiveret som standard. - ---- - -#### Tilgængelige værktøjer - -Her er alle de værktøjer, der kan styres gennem tilstandskonfigurationen. - -| Værktøj | Beskrivelse | -| ----------- | -------------------------- | -| `bash` | Udfør shell-kommandoer | -| `edit` | Rediger eksisterende filer | -| `write` | Opret nye filer | -| `read` | Læs filindhold | -| `grep` | Søg filindhold | -| `glob` | Find filer efter mønster | -| `patch` | Anvend patches til filer | -| `todowrite` | Administrer todo-lister | -| `webfetch` | Hent webindhold | - ---- - -## Brugerdefinerede tilstande - -Du kan oprette dine egne brugerdefinerede tilstande ved at tilføje dem til konfigurationen. Her er eksempler, der bruger begge tilgange: - -### Brug af JSON-konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Brug af markdown-filer - -Opret tilstandsfiler i `.opencode/modes/` for projektspecifikke tilstande eller `~/.config/opencode/modes/` for globale tilstande: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Brugsscenarier - -Her er nogle almindelige brugstilfælde for forskellige tilstande. - -- **Build mode**: Fuldt udviklingsarbejde med alle værktøjer aktiveret -- **Plantilstand**: Analyse og planlægning uden at foretage ændringer -- **Anmeldelsestilstand**: Kodegennemgang med skrivebeskyttet adgang plus dokumentationsværktøjer -- **Fejlretningstilstand**: Fokuseret på undersøgelse med bash og læseværktøjer aktiveret -- **Docs-tilstand**: Dokumentationsskrivning med filhandlinger, men ingen systemkommandoer - -Du kan også finde ud af, at forskellige modeller er gode til forskellige brugssituationer. diff --git a/packages/web/src/content/docs/de/modes.mdx b/packages/web/src/content/docs/de/modes.mdx deleted file mode 100644 index 11a010d6b915..000000000000 --- a/packages/web/src/content/docs/de/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modi -description: Verschiedene Modi für unterschiedliche Anwendungsfälle. ---- - -:::caution -Modi werden jetzt über die Option `agent` in der OpenCode-Konfiguration konfiguriert. Der -Die Option `mode` ist jetzt veraltet. [Learn more](/docs/agents). -::: - -Mit den Modi in OpenCode können Sie das Verhalten, die Tools und die Eingabeaufforderungen für verschiedene Anwendungsfälle anpassen. - -Es verfügt über zwei integrierte Modi: **Build** und **Plan**. Sie können anpassen -diese oder konfigurieren Sie Ihre eigenen über die OpenCode-Konfiguration. - -Sie können während einer Sitzung zwischen den Modi wechseln oder diese in Ihrer Konfigurationsdatei konfigurieren. - ---- - -## Integriert - -OpenCode verfügt über zwei integrierte Modi. - ---- - -### Build - -Build ist der **Standardmodus**, bei dem alle Tools aktiviert sind. Dies ist der Standardmodus für Entwicklungsarbeiten, bei dem Sie vollen Zugriff auf Dateioperationen und Systembefehle benötigen. - ---- - -### Plan - -Ein eingeschränkter Modus für Planung und Analyse. Im Planmodus sind die folgenden Tools standardmäßig deaktiviert: - -- `write` – Es können keine neuen Dateien erstellt werden -- `edit` – Vorhandene Dateien können nicht geändert werden, mit Ausnahme der Dateien unter `.opencode/plans/*.md`, um den Plan selbst detailliert darzustellen -- `patch` – Patches können nicht angewendet werden -- `bash` – Shell-Befehle können nicht ausgeführt werden - -Dieser Modus ist nützlich, wenn Sie möchten, dass AI Code analysiert, Änderungen vorschlägt oder Pläne erstellt, ohne tatsächliche Änderungen an Ihrer Codebasis vorzunehmen. - ---- - -## Wechseln - -Sie können während einer Sitzung mit der _Tab_-Taste zwischen den Modi wechseln. Oder Ihre konfigurierte `switch_mode`-Tastenkombination. - -Siehe auch: [Formatters](/docs/formatters) für Informationen zur Codeformatierungskonfiguration. - ---- - -## Konfiguration - -Sie können die integrierten Modi anpassen oder über die Konfiguration eigene erstellen. Modi können auf zwei Arten konfiguriert werden: - -### JSON-Konfiguration - -Konfigurieren Sie Modi in Ihrer `opencode.json`-Konfigurationsdatei: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-Konfiguration - -Sie können Modi auch mithilfe von Markdown-Dateien definieren. Platzieren Sie sie in: - -- Global: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Der Name der Markdown-Datei wird zum Modusnamen (e.g., `review.md` erstellt einen `review`-Modus). - -Schauen wir uns diese Konfigurationsmöglichkeiten im Detail an. - ---- - -### Model - -Verwenden Sie die `model`-Konfiguration, um das Standardmodell für diesen Modus zu überschreiben. Nützlich für die Verwendung verschiedener Modelle, die für verschiedene Aufgaben optimiert sind. Zum Beispiel ein schnelleres Modell für die Planung, ein leistungsfähigeres Modell für die Umsetzung. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Steuern Sie die Zufälligkeit und Kreativität der Antworten von AI mit der `temperature`-Konfiguration. Niedrigere Werte machen die Antworten fokussierter und deterministischer, während höhere Werte die Kreativität und Variabilität steigern. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturwerte liegen typischerweise zwischen 0.0 und 1.0: - -- **0.0-0.2**: Sehr fokussierte und deterministische Antworten, ideal für Code-Analyse und Planung -- **0.3-0.5**: Ausgewogene Antworten mit etwas Kreativität, gut für allgemeine Entwicklungsaufgaben -- **0.6-1.0**: Kreativere und vielfältigere Antworten, nützlich für Brainstorming und Erkundung - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Wenn keine Temperatur angegeben ist, verwendet OpenCode modellspezifische Standardwerte (normalerweise 0 für die meisten Modelle, 0.55 für Qwen-Modelle). - ---- - -### Prompt - -Geben Sie mit der `prompt`-Konfiguration eine benutzerdefinierte Systemaufforderungsdatei für diesen Modus an. Die Eingabeaufforderungsdatei sollte spezifische Anweisungen für den Zweck des Modus enthalten. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Dieser Pfad ist relativ zum Speicherort der Konfigurationsdatei. Das funktioniert also -sowohl die globale OpenCode-Konfiguration als auch die projektspezifische Konfiguration. - ---- - -### Werkzeuge - -Steuern Sie mit der `tools`-Konfiguration, welche Tools in diesem Modus verfügbar sind. Sie können bestimmte Tools aktivieren oder deaktivieren, indem Sie sie auf `true` oder `false` setzen. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Wenn keine Tools angegeben sind, sind alle Tools standardmäßig aktiviert. - ---- - -#### Verfügbare Tools - -Hier sind alle Tools aufgeführt, die über den Konfigurationsmodus gesteuert werden können. - -| Werkzeug | Beschreibung | -| ----------- | ---------------------------- | -| `bash` | Shell-Befehle ausführen | -| `edit` | Vorhandene Dateien ändern | -| `write` | Neue Dateien erstellen | -| `read` | Dateiinhalt lesen | -| `grep` | Dateiinhalte durchsuchen | -| `glob` | Dateien nach Muster suchen | -| `patch` | Patches auf Dateien anwenden | -| `todowrite` | Aufgabenlisten verwalten | -| `webfetch` | Webinhalte abrufen | - ---- - -## Benutzerdefinierte Modi - -Sie können Ihre eigenen benutzerdefinierten Modi erstellen, indem Sie diese zur Konfiguration hinzufügen. Hier sind Beispiele für beide Ansätze: - -### Verwenden der JSON-Konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Verwendung von Markdown-Dateien - -Erstellen Sie Modusdateien in `.opencode/modes/` für projektspezifische Modi oder `~/.config/opencode/modes/` für globale Modi: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Anwendungsfälle - -Hier sind einige häufige Anwendungsfälle für verschiedene Modi. - -- **Build-Modus**: Vollständige Entwicklungsarbeit mit allen aktivierten Tools -- **Planmodus**: Analyse und Planung ohne Änderungen -- **Überprüfungsmodus**: Codeüberprüfung mit schreibgeschütztem Zugriff plus Dokumentationstools -- **Debug-Modus**: Konzentriert sich auf die Untersuchung mit aktivierten Bash- und Lesetools -- **Docs-Modus**: Dokumentationsschreiben mit Dateioperationen, aber ohne Systembefehle - -Möglicherweise stellen Sie auch fest, dass unterschiedliche Modelle für unterschiedliche Anwendungsfälle geeignet sind. diff --git a/packages/web/src/content/docs/es/modes.mdx b/packages/web/src/content/docs/es/modes.mdx deleted file mode 100644 index dca900dff068..000000000000 --- a/packages/web/src/content/docs/es/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modos -description: Diferentes modos para diferentes casos de uso. ---- - -:::caution -Los modos ahora se configuran a través de la opción `agent` en la configuración opencode. El -La opción `mode` ahora está en desuso. [Más información](/docs/agents). -::: - -Los modos en opencode le permiten personalizar el comportamiento, las herramientas y las indicaciones para diferentes casos de uso. - -Viene con dos modos integrados: **construir** y **planificar**. Puedes personalizar -estos o configure el suyo propio a través de la configuración opencode. - -Puede cambiar entre modos durante una sesión o configurarlos en su archivo de configuración. - ---- - -## Integrados - -opencode viene con dos modos integrados. - ---- - -### Modo Build - -Build es el modo **predeterminado** con todas las herramientas habilitadas. Este es el modo estándar para el trabajo de desarrollo en el que necesita acceso completo a las operaciones de archivos y a los comandos del sistema. - ---- - -### Modo Plan - -Un modo restringido diseñado para la planificación y el análisis. En el modo de plan, las siguientes herramientas están deshabilitadas de forma predeterminada: - -- `write` - No se pueden crear archivos nuevos -- `edit` - No se pueden modificar archivos existentes, excepto los archivos ubicados en `.opencode/plans/*.md` para detallar el plan en sí. -- `patch` - No se pueden aplicar parches -- `bash` - No se pueden ejecutar comandos de shell - -Este modo es útil cuando desea que la IA analice código, sugiera cambios o cree planes sin realizar modificaciones reales en su base de código. - ---- - -## Cambiar de modo - -Puede cambiar entre modos durante una sesión usando la tecla _Tab_. O su combinación de teclas `switch_mode` configurada. - -Consulte también: [Formateadores](/docs/formatters) para obtener información sobre la configuración de formato de código. - ---- - -## Configuración - -Puede personalizar los modos integrados o crear los suyos propios mediante la configuración. Los modos se pueden configurar de dos maneras: - -### Configuración JSON - -Configure los modos en su archivo de configuración `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuración de Markdown - -También puede definir modos utilizando archivos de Markdown. Colócalos en: - -- Global: `~/.config/opencode/modes/` -- Proyecto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -El nombre del archivo de Markdown se convierte en el nombre del modo (por ejemplo, `review.md` crea un modo `review`). - -Veamos estas opciones de configuración en detalle. - ---- - -### Modelo - -Utilice la configuración `model` para anular el modelo predeterminado para este modo. Útil para utilizar diferentes modelos optimizados para diferentes tareas. Por ejemplo, un modelo más rápido de planificación, un modelo más capaz de implementación. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle la aleatoriedad y la creatividad de las respuestas de la IA con la configuración `temperature`. Los valores más bajos hacen que las respuestas sean más centradas y deterministas, mientras que los valores más altos aumentan la creatividad y la variabilidad. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Los valores de temperatura suelen oscilar entre 0,0 y 1,0: - -- **0.0-0.2**: Respuestas muy enfocadas y deterministas, ideales para análisis y planificación de código. -- **0,3-0,5**: respuestas equilibradas con algo de creatividad, buenas para tareas de desarrollo general. -- **0.6-1.0**: respuestas más creativas y variadas, útiles para la lluvia de ideas y la exploración. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si no se especifica ninguna temperatura, opencode utiliza valores predeterminados específicos del modelo (normalmente 0 para la mayoría de los modelos, 0,55 para los modelos Qwen). - ---- - -### Indicación - -Especifique un archivo de aviso del sistema personalizado para este modo con la configuración `prompt`. El archivo de aviso debe contener instrucciones específicas para el propósito del modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Esta ruta es relativa a donde se encuentra el archivo de configuración. Entonces esto funciona para -tanto la configuración global opencode como la configuración específica del proyecto. - ---- - -### Herramientas - -Controle qué herramientas están disponibles en este modo con la configuración `tools`. Puede habilitar o deshabilitar herramientas específicas configurándolas en `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si no se especifica ninguna herramienta, todas las herramientas están habilitadas de forma predeterminada. - ---- - -#### Herramientas disponibles - -Aquí están todas las herramientas que se pueden controlar a través del modo de configuración. - -| Herramienta | Descripción | -| ----------- | --------------------------------------- | -| `bash` | Ejecutar comandos de shell | -| `edit` | Modificar archivos existentes | -| `write` | Crear nuevos archivos | -| `read` | Leer el contenido del archivo | -| `grep` | Buscar contenido del archivo | -| `glob` | Buscar archivos por patrón | -| `patch` | Aplicar parches a archivos | -| `todowrite` | Administrar listas de tareas pendientes | -| `webfetch` | Obtener contenido web | - ---- - -## Modos personalizados - -Puede crear sus propios modos personalizados agregándolos a la configuración. A continuación se muestran ejemplos que utilizan ambos enfoques: - -### Usando la configuración JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando archivos de Markdown - -Cree archivos de modo en `.opencode/modes/` para modos específicos del proyecto o `~/.config/opencode/modes/` para modos globales: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -A continuación se muestran algunos casos de uso comunes para diferentes modos. - -- **Modo Build**: trabajo de desarrollo completo con todas las herramientas habilitadas -- **Modo Plan**: Análisis y planificación sin realizar cambios -- **Modo Review**: revisión de código con acceso de solo lectura más herramientas de documentación -- **Modo Debug**: centrado en la investigación con bash y herramientas de lectura habilitadas -- **Modo Docs**: escritura de documentacion con operaciones de archivos pero sin comandos del sistema - -También puede encontrar que diferentes modelos son buenos para diferentes casos de uso. diff --git a/packages/web/src/content/docs/fr/modes.mdx b/packages/web/src/content/docs/fr/modes.mdx deleted file mode 100644 index 6985dbd57d71..000000000000 --- a/packages/web/src/content/docs/fr/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: Modes -description: Différents modes pour différents cas d'utilisation. ---- - -:::caution -Les modes sont désormais configurés via l'option `agent` dans la configuration opencode. -L’option `mode` est désormais obsolète. [En savoir plus](/docs/agents). -::: - -Les modes dans opencode vous permettent de personnaliser le comportement, les outils et les prompts pour différents cas d'utilisation. - -Il est livré avec deux modes intégrés : **build** et **plan**. Vous pouvez personnaliser ceux-ci ou configurez les vôtres via la configuration opencode. - -Vous pouvez basculer entre les modes au cours d'une session ou les configurer dans votre fichier de configuration. - ---- - -## Modes intégrés - -opencode est livré avec deux modes intégrés. - ---- - -### Build - -Build est le mode **par défaut** avec tous les outils activés. Il s'agit du mode standard pour le travail de développement dans lequel vous avez besoin d'un accès complet aux opérations sur les fichiers et aux commandes système. - ---- - -### Plan - -Un mode restreint conçu pour la planification et l’analyse. En mode plan, les outils suivants sont désactivés par défaut : - -- `write` - Impossible de créer de nouveaux fichiers -- `edit` - Impossible de modifier les fichiers existants, à l'exception des fichiers situés à `.opencode/plans/*.md` pour détailler le plan lui-même -- `patch` - Impossible d'appliquer les correctifs -- `bash` - Impossible d'exécuter les commandes shell - -Ce mode est utile lorsque vous souhaitez que l'IA analyse le code, suggère des modifications ou crée des plans sans apporter de modifications réelles à votre base de code. - ---- - -## Changement de mode - -Vous pouvez basculer entre les modes au cours d'une session à l'aide de la touche _Tab_. Ou votre raccourci clavier `switch_mode` configuré. - -Voir également : [Formatters](/docs/formatters) pour plus d'informations sur la configuration du formatage du code. - ---- - -## Configuration - -Vous pouvez personnaliser les modes intégrés ou créer les vôtres via la configuration. Les modes peuvent être configurés de deux manières : - -### Configuration JSON - -Configurez les modes dans votre fichier de configuration `opencode.json` : - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuration Markdown - -Vous pouvez également définir des modes à l'aide de fichiers markdown. Placez-les dans : - -- Global : `~/.config/opencode/modes/` -- Projet : `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Le nom du fichier markdown devient le nom du mode (par exemple, `review.md` crée un mode `review`). - -Examinons ces options de configuration en détail. - ---- - -### Modèle - -Utilisez la configuration `model` pour remplacer le modèle par défaut pour ce mode. Utile pour utiliser différents modèles optimisés pour différentes tâches. Par exemple, un modèle de planification plus rapide, un modèle de mise en œuvre plus performant. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Température - -Contrôlez le caractère aléatoire et la créativité des réponses de l'IA avec la configuration `temperature`. Des valeurs faibles rendent les réponses plus ciblées et déterministes, tandis que des valeurs plus élevées augmentent la créativité et la variabilité. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Les valeurs de température varient généralement de 0,0 à 1,0 : - -- **0,0-0,2** : réponses très ciblées et déterministes, idéales pour l'analyse et la planification du code -- **0,3-0,5** : réponses équilibrées avec une certaine créativité, idéales pour les tâches de développement générales -- **0,6-1,0** : réponses plus créatives et variées, utiles pour le brainstorming et l'exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si aucune température n'est spécifiée, opencode utilise les valeurs par défaut spécifiques au modèle (généralement 0 pour la plupart des modèles, 0,55 pour les modèles Qwen). - ---- - -### Invite - -Spécifiez un fichier de prompt système personnalisé pour ce mode avec la configuration `prompt`. Le fichier de prompt doit contenir des instructions spécifiques à l'objectif du mode. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ce chemin est relatif à l'emplacement du fichier de configuration. Donc ça marche pour à la fois la configuration globale opencode et la configuration spécifique au projet. - ---- - -### Outils - -Contrôlez quels outils sont disponibles dans ce mode avec la configuration `tools`. Vous pouvez activer ou désactiver des outils spécifiques en les définissant sur `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si aucun outil n'est spécifié, tous les outils sont activés par défaut. - ---- - -#### Outils disponibles - -Voici tous les outils pouvant être contrôlés via le mode config. - -| Outil | Description | -| ----------- | ------------------------------------- | -| `bash` | Exécuter des commandes shell | -| `edit` | Modifier des fichiers existants | -| `write` | Créer de nouveaux fichiers | -| `read` | Lire le contenu du fichier | -| `grep` | Rechercher le contenu du fichier | -| `glob` | Rechercher des fichiers par modèle | -| `patch` | Appliquer des correctifs aux fichiers | -| `todowrite` | Gérer les listes de tâches | -| `webfetch` | Récupérer du contenu Web | - ---- - -## Modes personnalisés - -Vous pouvez créer vos propres modes personnalisés en les ajoutant à la configuration. Voici des exemples utilisant les deux approches : - -### Utilisation de la configuration JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Utiliser des fichiers Markdown - -Créez des fichiers de mode dans `.opencode/modes/` pour les modes spécifiques au projet ou `~/.config/opencode/modes/` pour les modes globaux : - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Cas d'utilisation - -Voici quelques cas d’utilisation courants pour différents modes. - -- **Mode Build** : travail de développement complet avec tous les outils activés -- **Mode Plan** : Analyse et planification sans apporter de modifications -- **Mode Review** : révision du code avec accès en lecture seule et outils de documentation -- **Mode Debug** : axé sur l'investigation avec les outils bash et read activés -- **Mode Docs** : écriture de documentation avec des opérations sur les fichiers mais pas de commandes système - -Vous constaterez peut-être également que différents modèles conviennent à différents cas d’utilisation. diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx deleted file mode 100644 index 052808f7ac2d..000000000000 --- a/packages/web/src/content/docs/it/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Modalità -description: Modalita diverse per casi d'uso diversi. ---- - -:::caution -Le modalita ora si configurano tramite l'opzione `agent` nella configurazione di opencode. L'opzione -`mode` e ora deprecata. [Scopri di piu](/docs/agents). -::: - -Le modalita in opencode ti permettono di personalizzare comportamento, strumenti e prompt per casi d'uso diversi. - -Include due modalita integrate: **build** e **plan**. Puoi personalizzarle oppure configurarne di tue tramite la configurazione di opencode. - -Puoi passare da una modalita all'altra durante una sessione oppure configurarle nel file di configurazione. - ---- - -## Integrate - -opencode include due modalita integrate. - ---- - -### Build - -Build e la modalita **predefinita** con tutti gli strumenti abilitati. E la modalita standard per il lavoro di sviluppo quando ti serve accesso completo alle operazioni sui file e ai comandi di sistema. - ---- - -### Plan - -Una modalita limitata pensata per pianificazione e analisi. In modalita plan, i seguenti strumenti sono disabilitati per impostazione predefinita: - -- `write` - Non puo creare nuovi file -- `edit` - Non puo modificare file esistenti, tranne i file in `.opencode/plans/*.md` per dettagliare il piano -- `patch` - Non puo applicare patch -- `bash` - Non puo eseguire comandi shell - -Questa modalita e utile quando vuoi che l'AI analizzi il codice, suggerisca modifiche o crei piani senza apportare modifiche effettive alla codebase. - ---- - -## Cambiare modalità - -Puoi cambiare modalita durante una sessione usando il tasto _Tab_. In alternativa, puoi usare il keybind `switch_mode` che hai configurato. - -Vedi anche: [Formatter](/docs/formatters) per informazioni sulla configurazione della formattazione del codice. - ---- - -## Configurazione - -Puoi personalizzare le modalita integrate o crearne di tue tramite configurazione. Le modalita si possono configurare in due modi: - -### Configurazione JSON - -Configura le modalita nel file di configurazione `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configurazione Markdown - -Puoi anche definire modalita usando file markdown. Mettili in: - -- Globale: `~/.config/opencode/modes/` -- Progetto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Il nome del file markdown diventa il nome della modalita (ad es. `review.md` crea una modalita `review`). - -Vediamo queste opzioni di configurazione nel dettaglio. - ---- - -### Modello - -Usa la configurazione `model` per sovrascrivere il modello predefinito per questa modalita. E utile per usare modelli diversi ottimizzati per task diversi: per esempio, un modello piu veloce per la pianificazione e uno piu capace per l'implementazione. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controlla casualita e creativita delle risposte dell'AI con la configurazione `temperature`. Valori piu bassi rendono le risposte piu focalizzate e deterministiche, mentre valori piu alti aumentano creativita e variabilita. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -I valori di temperatura di solito vanno da 0.0 a 1.0: - -- **0.0-0.2**: risposte molto focalizzate e deterministiche, ideali per analisi del codice e pianificazione -- **0.3-0.5**: risposte bilanciate con un po' di creativita, buone per task di sviluppo generici -- **0.6-1.0**: risposte piu creative e variabili, utili per brainstorming ed esplorazione - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se non viene specificata alcuna temperatura, opencode usa i default specifici del modello (tipicamente 0 per la maggior parte dei modelli, 0.55 per i modelli Qwen). - ---- - -### Prompt - -Specifica un file di prompt di sistema personalizzato per questa modalita con la configurazione `prompt`. Il file dovrebbe contenere istruzioni specifiche per lo scopo della modalita. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Questo percorso e relativo a dove si trova il file di configurazione. Quindi funziona -sia per la configurazione globale di opencode sia per quella specifica del progetto. - ---- - -### Strumenti - -Controlla quali strumenti sono disponibili in questa modalita con la configurazione `tools`. Puoi abilitare o disabilitare strumenti specifici impostandoli a `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se non specifichi gli strumenti, tutti gli strumenti sono abilitati per impostazione predefinita. - ---- - -#### Strumenti disponibili - -Ecco tutti gli strumenti che possono essere controllati tramite la configurazione della modalita. - -| Strumento | Descrizione | -| ----------- | ---------------------------- | -| `bash` | Esegue comandi shell | -| `edit` | Modifica file esistenti | -| `write` | Crea nuovi file | -| `read` | Legge contenuti dei file | -| `grep` | Cerca nei contenuti dei file | -| `glob` | Trova file per pattern | -| `patch` | Applica patch ai file | -| `todowrite` | Gestisce liste todo | -| `webfetch` | Recupera contenuti web | - ---- - -## Modalità personalizzate - -Puoi creare modalita personalizzate aggiungendole alla configurazione. Ecco esempi con entrambi gli approcci: - -### Usando la configurazione JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando file markdown - -Crea file di modalita in `.opencode/modes/` per modalita specifiche del progetto oppure in `~/.config/opencode/modes/` per modalita globali: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casi d'uso - -Ecco alcuni casi d'uso comuni per le diverse modalita. - -- **Modalita Build**: sviluppo completo con tutti gli strumenti abilitati -- **Modalita Plan**: analisi e pianificazione senza apportare modifiche -- **Modalita Review**: code review con accesso in sola lettura piu strumenti di documentazione -- **Modalita Debug**: focalizzata sull'investigazione con strumenti bash e read abilitati -- **Modalita Docs**: scrittura di documentazione con operazioni sui file ma senza comandi di sistema - -Potresti anche trovare che modelli diversi funzionano meglio per casi d'uso diversi. diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx deleted file mode 100644 index 8ebe7f5e68dc..000000000000 --- a/packages/web/src/content/docs/ja/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: モード -description: さまざまなユースケースに応じたさまざまなモード。 ---- - -:::caution -モードは、OpenCode 設定の `agent` オプションを通じて設定されるようになりました。 -`mode` オプションは非推奨になりました。 [詳細はこちら](/docs/agents)。 -::: -モードを使用すると、さまざまなユースケースに合わせて動作、ツール、プロンプトをカスタマイズできます。 - -**Build** と **Plan** という 2 つの組み込みモードが付属しています。カスタマイズできます -これらを使用するか、OpenCode 設定を通じて独自の設定を行います。 - -セッション中にモードを切り替えることも、設定ファイルでモードを構成することもできます。 - ---- - -## 組み込み - -OpenCode には 2 つの組み込みモードが付属しています。 - ---- - -### Build - -Build は、すべてのツールが有効になっている **デフォルト** モードです。これは、ファイル操作やシステムコマンドへのフルアクセスが必要な開発作業の標準モードです。 - ---- - -### Plan - -計画と分析のために設計された制限付きモード。Plan モードでは、次のツールはデフォルトで無効になっています。 - -- `write` - 新しいファイルを作成できません -- `edit` - 計画自体の詳細を示す `.opencode/plans/*.md` にあるファイルを除き、既存のファイルを変更できません -- `patch` - パッチを適用できません -- `bash` - シェルコマンドを実行できません - -このモードは、コードベースに実際の変更を加えずに、AI にコードを分析させたり、変更を提案したり、計画を作成させたい場合に便利です。 - ---- - -## 切り替え - -セッション中に _Tab_ キーを使用してモードを切り替えることができます。または、設定された `switch_mode` キーバインド。 - -参照: [コードのフォーマット設定については、Formatters](/docs/formatters)。 - ---- - -## 設定 - -組み込みモードをカスタマイズしたり、構成を通じて独自のモードを作成したりできます。モードは次の 2 つの方法で設定できます。 - -### JSON 設定 - -`opencode.json` 設定ファイルでモードを構成します。 - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -Markdown ファイルを使用してモードを定義することもできます。それらを次の場所に置きます。 - -- グローバル: `~/.config/opencode/modes/` -- プロジェクト: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown ファイル名はモード名になります (例: `review.md` は `review` モードを作成します)。 - -これらの設定オプションを詳しく見てみましょう。 - ---- - -### モデル - -`model` 設定を使用して、このモードのデフォルトモデルをオーバーライドします。さまざまなタスクに最適化されたさまざまなモデルを使用する場合に役立ちます。たとえば、計画にはより高速なモデルを、実装にはより有能なモデルを使用します。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -`temperature` 設定を使用して、AI の応答のランダム性と創造性を制御します。値が低いほど、応答はより集中的かつ決定的になりますが、値が高いほど、創造性と変動性が高まります。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -通常、温度値の範囲は 0.0 ~ 1.0 です。 - -- **0.0-0.2**: 非常に焦点が絞られた決定的な応答。コード分析と計画に最適です。 -- **0.3-0.5**: 創造性を備えたバランスの取れた応答。一般的な開発タスクに適しています。 -- **0.6-1.0**: より創造的で多様な応答。ブレーンストーミングや探索に役立ちます。 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -温度が指定されていない場合、OpenCode はモデル固有のデフォルトを使用します (通常、ほとんどのモデルでは 0、Qwen モデルでは 0.55)。 - ---- - -### プロンプト - -`prompt` 設定を使用して、このモードのカスタムシステムプロンプトファイルを指定します。プロンプトファイルには、モードの目的に固有の指示が含まれている必要があります。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -このパスは、設定ファイルが配置されている場所に対する相対パスです。したがって、これはグローバルな OpenCode 設定とプロジェクト固有の設定の両方で機能します。 - ---- - -### ツール - -`tools` 設定を使用して、このモードでどのツールを使用できるかを制御します。特定のツールを `true` または `false` に設定することで、有効または無効にすることができます。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -ツールが指定されていない場合は、すべてのツールがデフォルトで有効になります。 - ---- - -#### 利用可能なツール - -ここでは、モード設定を通じて制御できるすべてのツールを示します。 - -| ツール | 説明 | -| ----------- | -------------------------- | -| `bash` | シェルコマンドを実行する | -| `edit` | 既存のファイルを変更する | -| `write` | 新しいファイルを作成する | -| `read` | ファイルの内容を読み取る | -| `grep` | ファイルの内容を検索 | -| `glob` | パターンでファイルを検索 | -| `patch` | ファイルにパッチを適用する | -| `todowrite` | ToDo リストを管理する | -| `webfetch` | Web コンテンツを取得する | - ---- - -## カスタムモード - -構成に追加することで、独自のカスタムモードを作成できます。両方のアプローチを使用した例を次に示します。 - -### JSON 設定の使用 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown ファイルの使用 - -モードファイルをプロジェクト固有モードの場合は `.opencode/modes/` に、グローバルモードの場合は `~/.config/opencode/modes/` に作成します。 - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### ユースケース - -さまざまなモードの一般的な使用例をいくつか示します。 - -- **ビルドモード**: すべてのツールを有効にした完全な開発作業 -- **計画モード**: 変更を加えずに分析および計画を立てる -- **レビューモード**: 読み取り専用アクセスとドキュメントツールによるコードレビュー -- **デバッグモード**: bash および読み取りツールを有効にして調査に重点を置きます -- **ドキュメントモード**: ファイル操作を使用してドキュメントを作成しますが、システムコマンドは使用しません - -また、さまざまなユースケースにさまざまなモデルが適していることがわかるかもしれません。 diff --git a/packages/web/src/content/docs/ko/modes.mdx b/packages/web/src/content/docs/ko/modes.mdx deleted file mode 100644 index 32746d1d057c..000000000000 --- a/packages/web/src/content/docs/ko/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: 모드 -description: 다양한 사용 사례를 위한 다양한 모드. ---- - -:::caution -모드는 opencode 설정에서 `agent` 옵션을 통해 구성되어 있습니다. 더 보기 -`mode` 옵션이 이제 비활성화되었습니다. [더 알아보기](/docs/agents). -::: - -opencode의 모드는 다른 사용 사례에 대한 행동, 도구 및 프롬프트를 사용자 정의 할 수 있습니다. - -그것은 두 개의 내장 모드와 함께 제공됩니다 : ** 빌드 ** 및 ** 계획 **. 사용자 정의 할 수 있습니다. -opencode config를 통해 자체를 구성합니다. - -세션 중에 모드를 전환하거나 구성 파일에서 구성할 수 있습니다. - ---- - -## 내장 - -opencode는 2개의 붙박이 형태로 옵니다. - ---- - -### 빌드 - -빌드는 **default** 모드로 모든 도구가 활성화됩니다. 이것은 파일 운영 및 시스템 명령에 대한 전체 액세스가 필요한 개발 작업을위한 표준 모드입니다. - ---- - -## 계획 - -계획 및 분석을 위해 설계된 제한 모드. 계획 모드에서 다음 도구는 기본적으로 비활성화됩니다: - -- `write` - 새로운 파일을 만들 수 없습니다 -- `edit` - `.opencode/plans/*.md`에 위치한 파일을 제외하고 기존 파일을 수정할 수 없습니다. -- `patch` - 패치 적용 -- `bash` - shell 명령을 실행할 수 없습니다 - -이 모드는 코드를 분석하기 위해 AI를 원할 때 유용합니다. 변경 사항을 제안하거나 코드베이스에 실제 수정없이 계획을 만들 수 있습니다. - ---- - -## 전환 - -Tab 키를 사용하여 세션 중에 모드를 전환할 수 있습니다. 또는 당신의 형성된 `switch_mode` keybind. - -참조 : [Formatters](/docs/formatters) 코드 형식 설정에 대한 정보. - ---- - -## 구성 - -내장 모드를 사용자 정의하거나 구성을 통해 자신의 만들 수 있습니다. 형태는 2가지 방법으로 형성될 수 있습니다: - -### JSON 구성 - -`opencode.json` 설정 파일에서 모드 구성: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 구성 - -Markdown 파일을 사용하여 모드를 정의할 수 있습니다. 그들에 게: - -- 글로벌: `~/.config/opencode/modes/` -- 프로젝트: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 파일 이름은 모드 이름 (예 : `review.md`는 `review` 모드를 만듭니다)이됩니다. - -이 구성 옵션을 자세히 살펴봅시다. - ---- - -### 모델 - -`model` config를 사용하여 이 모드의 기본 모델을 무시합니다. 다른 작업에 최적화 된 다른 모델을 사용하는 데 유용합니다. 예를 들어, 계획을위한 빠른 모델, 구현을위한 더 많은 모델. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 온도 - -`temperature` config와 AI의 응답의 임의성과 창의성을 제어합니다. 더 낮은 값은 더 집중하고 세심한 응답을 만듭니다. 더 높은 값은 창의력과 가변성을 증가하면서. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -온도 값은 일반적으로 0.0에서 1.0에 배열합니다: - -- **0.0-0.2**: 매우 집중하고 신중한 응답, 코드 분석 및 계획에 이상 -**0.3-0.5**: 일부 창의력과 균형 잡힌 응답, 일반 개발 작업에 좋은 -- **0.6-1.0**: 더 창조적이고 다양한 응답, 브레인스토밍 및 탐험에 유용한 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -온도가 지정되지 않은 경우, opencode는 모델별 기본 (일반적으로 0 대부분의 모델에 대한, 0.55 Qwen 모델)을 사용합니다. - ---- - -#### 프롬프트 - -`prompt` config를 가진 이 형태를 위한 주문 체계 prompt 파일을 지정하십시오. prompt 파일은 모드의 목적에 특정한 지시를 포함해야 합니다. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -이 경로는 config 파일이 있는 곳에 관계됩니다. 그래서이 작품 -글로벌 opencode config 및 프로젝트 특정 구성 모두. - ---- - -## 도구 - -이 모드에서는 `tools` config를 사용할 수 있습니다. `true` 또는 `false`로 설정하여 특정 도구를 활성화하거나 비활성화 할 수 있습니다. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -도구가 지정되지 않은 경우, 모든 도구는 기본적으로 활성화됩니다. - ---- - -### 유효한 도구 - -여기에 모든 도구는 모드 구성을 통해 제어 할 수 있습니다. - -| 도구 | 설명 | -| ----------- | --------------------- | -| `bash` | shell 명령 실행 | -| `edit` | 기존 파일 수정 | -| `write` | 새 파일 만들기 | -| `read` | 읽는 파일 내용 | -| `grep` | 파일 검색 | -| `glob` | 패턴으로 찾기 | -| `patch` | 파일에 패치 적용 | -| `todowrite` | 할 일(Todo) 목록 관리 | -| `webfetch` | 웹사이트 가져오기 | - ---- - -## 사용자 정의 모드 - -구성에 추가하여 사용자 정의 모드를 만들 수 있습니다. 여기에는 두 가지 접근법이 있습니다. - -### JSON 구성 사용 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### markdown 파일 사용 - -프로젝트 별 모드 또는 `~/.config/opencode/modes/`의 모드 파일을 만들 수 있습니다. - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 사용 사례 - -다음은 다른 모드에 대한 일반적인 사용 사례입니다. - -- **Build 모드**: 모든 도구와 함께 전체 개발 작업 -- **Plan 모드**: 변화없이 분석 및 계획 -**Review 모드**: Code review with read-only access plus 문서 도구 -- **Debug 모드**: bash 및 읽기 도구와 함께 조사에 집중 -- **Docs 모드**: 파일 작업과 문서 작성하지만 시스템 명령 없음 - -다른 모델을 찾을 수 있습니다 다른 사용 케이스에 대 한 좋은. diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx deleted file mode 100644 index b8ea697399d7..000000000000 --- a/packages/web/src/content/docs/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modes -description: Different modes for different use cases. ---- - -:::caution -Modes are now configured through the `agent` option in the opencode config. The -`mode` option is now deprecated. [Learn more](/docs/agents). -::: - -Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases. - -It comes with two built-in modes: **build** and **plan**. You can customize -these or configure your own through the opencode config. - -You can switch between modes during a session or configure them in your config file. - ---- - -## Built-in - -opencode comes with two built-in modes. - ---- - -### Build - -Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands. - ---- - -### Plan - -A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default: - -- `write` - Cannot create new files -- `edit` - Cannot modify existing files, except for files located at `.opencode/plans/*.md` to detail the plan itself -- `patch` - Cannot apply patches -- `bash` - Cannot execute shell commands - -This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase. - ---- - -## Switching - -You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind. - -See also: [Formatters](/docs/formatters) for information about code formatting configuration. - ---- - -## Configure - -You can customize the built-in modes or create your own through configuration. Modes can be configured in two ways: - -### JSON Configuration - -Configure modes in your `opencode.json` config file: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Configuration - -You can also define modes using markdown files. Place them in: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -The markdown file name becomes the mode name (e.g., `review.md` creates a `review` mode). - -Let's look at these configuration options in detail. - ---- - -### Model - -Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature - -Control the randomness and creativity of the AI's responses with the `temperature` config. Lower values make responses more focused and deterministic, while higher values increase creativity and variability. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperature values typically range from 0.0 to 1.0: - -- **0.0-0.2**: Very focused and deterministic responses, ideal for code analysis and planning -- **0.3-0.5**: Balanced responses with some creativity, good for general development tasks -- **0.6-1.0**: More creative and varied responses, useful for brainstorming and exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -If no temperature is specified, opencode uses model-specific defaults (typically 0 for most models, 0.55 for Qwen models). - ---- - -### Prompt - -Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -This path is relative to where the config file is located. So this works for -both the global opencode config and the project specific config. - ---- - -### Tools - -Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -If no tools are specified, all tools are enabled by default. - ---- - -#### Available tools - -Here are all the tools can be controlled through the mode config. - -| Tool | Description | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Custom modes - -You can create your own custom modes by adding them to the configuration. Here are examples using both approaches: - -### Using JSON configuration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Using markdown files - -Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Use cases - -Here are some common use cases for different modes. - -- **Build mode**: Full development work with all tools enabled -- **Plan mode**: Analysis and planning without making changes -- **Review mode**: Code review with read-only access plus documentation tools -- **Debug mode**: Focused on investigation with bash and read tools enabled -- **Docs mode**: Documentation writing with file operations but no system commands - -You might also find different models are good for different use cases. diff --git a/packages/web/src/content/docs/nb/modes.mdx b/packages/web/src/content/docs/nb/modes.mdx deleted file mode 100644 index e99c511be6c2..000000000000 --- a/packages/web/src/content/docs/nb/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Moduser -description: Ulike moduser for forskjellige brukstilfeller. ---- - -:::caution -Moduser konfigureres nå gjennom alternativet `agent` i OpenCode-konfigurasjonen. -Alternativet `mode` er nå utdatert. [Finn ut mer](/docs/agents). -::: - -Moduser i OpenCode lar deg tilpasse oppførselen, verktøyene og prompter for ulike brukstilfeller. - -Den kommer med to innebygde moduser: **bygg** og **plan**. Du kan tilpasse -disse eller konfigurer din egen gjennom OpenCode-konfigurasjonen. - -Du kan bytte mellom moduser under en økt eller konfigurere dem i konfigurasjonsfilen din. - ---- - -## Innebygd - -OpenCode kommer med to innebygde moduser. - ---- - -### Bygg - -Bygg er **standard**-modusen med alle verktøy aktivert. Dette er standardmodusen for utviklingsarbeid der du trenger full tilgang til filoperasjoner og systemkommandoer. - ---- - -### Plan - -En begrenset modus designet for planlegging og analyse. I planmodus er følgende verktøy deaktivert som standard: - -- `write` - Kan ikke opprette nye filer -- `edit` - Kan ikke endre eksisterende filer, bortsett fra filer som ligger på `.opencode/plans/*.md` for å detaljere selve planen -- `patch` - Kan ikke bruke patcher -- `bash` - Kan ikke utføre shell-kommandoer - -Denne modusen er nyttig når du vil at AI skal analysere kode, foreslå endringer eller lage planer uten å gjøre noen faktiske endringer i kodebasen. - ---- - -## Bytte - -Du kan bytte mellom moduser under en økt ved å bruke _Tab_-tasten. Eller din konfigurerte `switch_mode` hurtigtast. - -Se også: [Formatters](/docs/formatters) for informasjon om kodeformateringskonfigurasjon. - ---- - -## Konfigurer - -Du kan tilpasse de innebygde modusene eller opprette din egen gjennom konfigurasjon. Moduser kan konfigureres på to måter: - -### JSON-konfigurasjon - -Konfigurer moduser i din `opencode.json` konfigurasjonsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfigurasjon - -Du kan også definere moduser ved hjelp av markdown-filer. Plasser dem i: - -- Globalt: `~/.config/opencode/modes/` -- Prosjekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Filnavnet til markdown-filen blir modusnavnet (f.eks. `review.md` oppretter en `review`-modus). - -La oss se på disse konfigurasjonsalternativene i detalj. - ---- - -### Modell - -Bruk `model`-konfigurasjonen for å overstyre standardmodellen for denne modusen. Nyttig for å bruke forskjellige modeller optimalisert for forskjellige oppgaver. For eksempel en raskere modell for planlegging, en mer kapabel modell for implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Kontroller tilfeldigheten og kreativiteten til AI-ens svar med `temperature`-konfigurasjonen. Lavere verdier gjør svarene mer fokuserte og deterministiske, mens høyere verdier øker kreativiteten og variasjonen. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturverdier varierer vanligvis fra 0,0 til 1,0: - -- **0.0-0.2**: Veldig fokuserte og deterministiske svar, ideelt for kodeanalyse og planlegging -- **0.3-0.5**: Balanserte svar med litt kreativitet, bra for generelle utviklingsoppgaver -- **0.6-1.0**: Mer kreative og varierte svar, nyttig for idédugnad og utforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis ingen temperatur er spesifisert, bruker OpenCode modellspesifikke standardinnstillinger (vanligvis 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angi en tilpasset systemprompt-fil for denne modusen med `prompt`-konfigurasjonen. Prompt-filen skal inneholde instruksjoner som er spesifikke for modusens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne banen er relativ til der konfigurasjonsfilen er plassert. Så dette fungerer for både den globale OpenCode-konfigurasjonen og den prosjektspesifikke konfigurasjonen. - ---- - -### Verktøy - -Kontroller hvilke verktøy som er tilgjengelige i denne modusen med `tools`-konfigurasjonen. Du kan aktivere eller deaktivere spesifikke verktøy ved å sette dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis ingen verktøy er spesifisert, er alle verktøy aktivert som standard. - ---- - -#### Tilgjengelige verktøy - -Her er alle verktøyene som kan kontrolleres gjennom moduskonfigurasjonen. - -| Verktøy | Beskrivelse | -| ----------- | --------------------------- | -| `bash` | Utfør shell-kommandoer | -| `edit` | Endre eksisterende filer | -| `write` | Opprett nye filer | -| `read` | Les filinnhold | -| `grep` | Søk i filinnhold | -| `glob` | Finn filer etter mønster | -| `patch` | Bruk patcher på filer | -| `todowrite` | Administrer gjøremålslister | -| `webfetch` | Hent webinnhold | - ---- - -## Egendefinerte moduser - -Du kan opprette dine egne tilpassede moduser ved å legge dem til i konfigurasjonen. Her er eksempler på bruk av begge metodene: - -### Bruke JSON-konfigurasjon - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Bruke markdown-filer - -Opprett modusfiler i `.opencode/modes/` for prosjektspesifikke moduser eller `~/.config/opencode/modes/` for globale moduser: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Bruksområder - -Her er noen vanlige bruksområder for forskjellige moduser. - -- **Bygg-modus**: Fullt utviklingsarbeid med alle verktøy aktivert -- **Plan-modus**: Analyse og planlegging uten å gjøre endringer -- **Review-modus**: Kodegjennomgang med skrivebeskyttet tilgang pluss dokumentasjonsverktøy -- **Debug-modus**: Fokusert på etterforskning med bash- og leseverktøy aktivert -- **Docs-modus**: Dokumentasjonsskriving med filoperasjoner, men ingen systemkommandoer - -Du kan også finne at forskjellige modeller er gode for forskjellige bruksområder. diff --git a/packages/web/src/content/docs/pl/modes.mdx b/packages/web/src/content/docs/pl/modes.mdx deleted file mode 100644 index 8d7c2568d34c..000000000000 --- a/packages/web/src/content/docs/pl/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tryby -description: Różne tryby dla różnych zastosowań. ---- - -:::caution -Tryby są teraz konfigurowane za pomocą opcji `agent` w konfiguracji opencode. -Opcja `mode` jest obecnie przestarzała. [Dowiedz się więcej](/docs/agents). -::: - -Tryb udostępniania możliwości stosowania, narzędzie i podpowiedzi do różnych zastosowań. - -Posiadanie dwa tryby: **Build** i **Plan**. Można dostosować -te lub skonfiguruj własne za pomocą konfiguracji opencode. - -Można przełączać się między trybami podczas sesji lub konfigurować je w pliku konfiguracyjnym. - ---- - -## Wbudowane - -opencode ma dwa puste tryby. - ---- - -### Build - -Build jest trybem **domyślnym** z dostępnymi narzędziami. Jest to standardowy tryb pracy programistycznej, który jest dostępny z pełnym dostępem do operacji na plikach i oryginalnych systemach systemowych. - ---- - -### Plan - -Tryb ograniczony do analizy. W urządzeniu planowym narzędzia są przydatne: - -- `write` - Nie można stworzyć nowych plików -- `edit` — Nie można zastosować naruszenia plików, z naruszeniem praw autorskich w `.opencode/plans/*.md` w celu uszczegółowienia samego planu -- `patch` - Nie można zastosować poprawek -- `bash` - Nie można wykonać poleceń shell - -Ten tryb jest alternatywny, gdy chcesz, aby sztuczna inteligencja analizowała kod, sugerowała zmianę lub tworzyła projekty bez źródła zewnętrznego, które stanowi bazę kodu. - ---- - -## Przełączanie - -Możesz przełączać się między trybami podczas sesji za pomocą klawisza _Tab_. Lub skrót klawiszowy `switch_mode`. - -Zobacz także: [Formaterzy](/docs/formatters), aby uzyskać informacje na temat konfiguracji formatowania kodu. - ---- - -## Skonfiguruj - -Możliwość dostosowania alternatywnego trybu lub konfiguracji poprzez własną konfigurację. Tryb można skonfigurować na dwa systemy: - -### Konfiguracja JSON - -Skonfiguruj tryb w pliku konfiguracyjnym `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Konfiguracja Markdown - -Można także definiować tryby za pomocą plików przecen. Trzymaj je w: - -- Globalnie: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Nazwa pliku przecen staje się kluczem trybu (np. `review.md` tworzy tryb `review`). - -Przyjrzyjmy się szczegółowo tym opcjom konfiguracji. - ---- - -### Model - -Zastosowanie konstrukcji `model`, aby zastosować domyślny model dla tego trybu. Przydatne przy wykorzystaniu różnych modeli dostępnych w różnych zadaniach. Na przykład zastosowanie modelu, skuteczniejsze wykonanie modelu. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontroluj losowość i kreatywność reakcji AI za pomocą konstrukcji `temperature`. Niższe wartości, że odpowiedzi są bardziej skupione i deterministyczne, podczas gdy najwyższa wartość jest innowacyjna i złożona. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Wartości temperatury zazwyczaj wahają się od 0,0 do 1,0: - -- **0,0-0,2**: Bardzo skoncentrowane i deterministyczne odpowiedzi, idealne do analizy i kodu źródłowego -- **0,3-0,5**: Zrównoważona odpowiedź z chwilą powstania, dobre do ogólnych zadań rozwojowych -- **0,6–1,0**: Bardziej kreatywne i odpowiedzi, rozstrzygnięte podczas burzy mózgów i eksploracji - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Jeśli nie ma wpływu na temperaturę, opencode stosuje się narzędzia badawcze dla modelu (zwykle 0 dla największych modeli, 0,55 dla modeli Qwen). - ---- - -### Prompt (Monit) - -niestandardowy plik podpowiedzi systemowych dla tej procedury za pomocą konfiguracji `prompt`. Plik informacyjny powinien zawierać instrukcje dotyczące przeznaczenia trybu. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ścieżka ta zależy od miejsca, w którym znajduje się plik konfiguracyjny. Więc to dla działa -zarówno globalna opencode, jak i specjalna dla projektu. - ---- - -### Narzędzia - -Kontroluj, które narzędzia są dostępne w tym urządzeniu, za pomocą konfiguracji `tools`. Można włączyć lub dostępne narzędzie, ustawiając je na `true` lub `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Jeśli nie ma żadnych narzędzi, wszystkie narzędzia są wyłączone. - ---- - -#### Dostępne narzędzia - -Oto wszystkie narzędzia, które można sterować za pomocą konfiguracji trybów. - -| Narzędzie | Opis | -| ----------- | ------------------------------------- | -| `bash` | Wykonaj polecenia shell | -| `edit` | Modyfikuj istniejące pliki | -| `write` | Utwórz nowe pliki | -| `read` | Przeczytaj zawartość pliku | -| `grep` | Wyszukaj zawartość pliku | -| `glob` | Znajdź pliki według wzorca | -| `patch` | Zastosuj poprawki do plików | -| `todowrite` | Zarządzaj listami rzeczy do wykonania | -| `webfetch` | Pobierz zawartość internetową | - ---- - -## Tryby niestandardowe - -Możesz stworzyć własny tryb prywatny, dodając je do konfiguracji. Oto zastosowanie obu rozwiązań: - -### Korzystanie z konfiguracji JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korzystanie z plików Markdown - -Utwórz pliki trybów w `.opencode/modes/` dla trybów zapisanych dla projektu lub `~/.config/opencode/modes/` dla trybów globalnych: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Przypadki użycia - -Oto kilka typowych zastosowań dla różnych trybów. - -- **Build mode**: Pełne prace programistyczne z dostępnymi narzędziami -- **Plan mode**: Analiza i planowanie bez zmian -- **Review mode**: Przegląd kodu z możliwością odczytu i narzędzi do dokumentacji -- **Debug mode**: Koncentruje się na urządzeniu z dostępnymi narzędziami bash i odczytu -- **Docs mode**: Zapisywanie dokumentacji przy użyciu operacji na plikach, ale bez oryginalnych systemów systemowych - -Może się również zdarzyć, że różne modele są dobre w różnych wersjach użycia. diff --git a/packages/web/src/content/docs/pt-br/modes.mdx b/packages/web/src/content/docs/pt-br/modes.mdx deleted file mode 100644 index b53fb4fcb446..000000000000 --- a/packages/web/src/content/docs/pt-br/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: Modos -description: Modos diferentes para diferentes casos de uso. ---- - -:::caution -Os modos agora são configurados através da opção `agent` na configuração do opencode. A opção `mode` agora está obsoleta. [Saiba mais](/docs/agents). -::: - -Os modos no opencode permitem que você personalize o comportamento, as ferramentas e os prompts para diferentes casos de uso. - -Ele vem com dois modos integrados: **build** e **plan**. Você pode personalizar esses ou configurar os seus próprios através da configuração do opencode. - -Você pode alternar entre os modos durante uma sessão ou configurá-los no seu arquivo de configuração. - ---- - -## Integrados - -O opencode vem com dois modos integrados. - ---- - -### build - -Build é o modo **padrão** com todas as ferramentas habilitadas. Este é o modo padrão para trabalho de desenvolvimento onde você precisa de acesso total a operações de arquivos e comandos do sistema. - ---- - -### plan - -Um modo restrito projetado para planejamento e análise. No modo plan, as seguintes ferramentas estão desativadas por padrão: - -- `write` - Não pode criar novos arquivos -- `edit` - Não pode modificar arquivos existentes, exceto para arquivos localizados em `.opencode/plans/*.md` para detalhar o plano em si -- `patch` - Não pode aplicar patches -- `bash` - Não pode executar comandos de shell - -Este modo é útil quando você deseja que a IA analise o código, sugira alterações ou crie planos sem fazer modificações reais em sua base de código. - ---- - -## Alternando - -Você pode alternar entre modos durante uma sessão usando a tecla _Tab_. Ou sua tecla de atalho configurada `switch_mode`. - -Veja também: [Formatadores](/docs/formatters) para informações sobre configuração de formatação de código. - ---- - -## Configuração - -Você pode personalizar os modos integrados ou criar os seus próprios através da configuração. Os modos podem ser configurados de duas maneiras: - -### Configuração JSON - -Configure os modos no seu arquivo de configuração `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuração Markdown - -Você também pode definir modos usando arquivos markdown. Coloque-os em: - -- Global: `~/.config/opencode/modes/` -- Projeto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -O nome do arquivo markdown se torna o nome do modo (por exemplo, `review.md` cria um modo `review`). - -Vamos analisar essas opções de configuração em detalhes. - ---- - -### Modelo - -Use a configuração `model` para substituir o modelo padrão para este modo. Útil para usar diferentes modelos otimizados para diferentes tarefas. Por exemplo, um modelo mais rápido para planejamento, um modelo mais capaz para implementação. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle a aleatoriedade e a criatividade das respostas da IA com a configuração `temperature`. Valores mais baixos tornam as respostas mais focadas e determinísticas, enquanto valores mais altos aumentam a criatividade e a variabilidade. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Os valores de temperatura geralmente variam de 0.0 a 1.0: - -- **0.0-0.2**: Respostas muito focadas e determinísticas, ideais para análise de código e planejamento -- **0.3-0.5**: Respostas equilibradas com alguma criatividade, boas para tarefas de desenvolvimento geral -- **0.6-1.0**: Respostas mais criativas e variadas, úteis para brainstorming e exploração - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se nenhuma temperatura for especificada, o opencode usa padrões específicos do modelo (geralmente 0 para a maioria dos modelos, 0.55 para modelos Qwen). - ---- - -### Prompt - -Especifique um arquivo de prompt do sistema personalizado para este modo com a configuração `prompt`. O arquivo de prompt deve conter instruções específicas para o propósito do modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Este caminho é relativo a onde o arquivo de configuração está localizado. Portanto, isso funciona tanto para a configuração global do opencode quanto para a configuração específica do projeto. - ---- - -### Ferramentas - -Controle quais ferramentas estão disponíveis neste modo com a configuração `tools`. Você pode habilitar ou desabilitar ferramentas específicas definindo-as como `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se nenhuma ferramenta for especificada, todas as ferramentas estão habilitadas por padrão. - ---- - -#### Ferramentas disponíveis - -Aqui estão todas as ferramentas que podem ser controladas através da configuração do modo. - -| Ferramenta | Descrição | -| ----------- | ------------------------------- | -| `bash` | Executar comandos de shell | -| `edit` | Modificar arquivos existentes | -| `write` | Criar novos arquivos | -| `read` | Ler conteúdos de arquivos | -| `grep` | Pesquisar conteúdos de arquivos | -| `glob` | Encontrar arquivos por padrão | -| `patch` | Aplicar patches a arquivos | -| `todowrite` | Gerenciar listas de tarefas | -| `webfetch` | Buscar conteúdo da web | - ---- - -## Modos personalizados - -Você pode criar seus próprios modos personalizados adicionando-os à configuração. Aqui estão exemplos usando ambas as abordagens: - -### Usando configuração JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando arquivos markdown - -Crie arquivos de modo em `.opencode/modes/` para modos específicos do projeto ou `~/.config/opencode/modes/` para modos globais: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -Aqui estão alguns casos de uso comuns para diferentes modos. - -- **Modo Build**: Trabalho de desenvolvimento completo com todas as ferramentas habilitadas -- **Modo Plan**: Análise e planejamento sem fazer alterações -- **Modo Review**: Revisão de código com acesso somente leitura e ferramentas de documentação -- **Modo Debug**: Focado em investigação com ferramentas bash e de leitura habilitadas -- **Modo Docs**: Redação de documentação com operações de arquivo, mas sem comandos do sistema - -Você também pode descobrir que diferentes modelos são bons para diferentes casos de uso. diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx deleted file mode 100644 index e63c91ace42d..000000000000 --- a/packages/web/src/content/docs/ru/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Режимы -description: Различные режимы для разных случаев использования. ---- - -:::caution -Режимы теперь настраиваются с помощью опции `agent` в конфигурации opencode. -Опция `mode` устарела. [Подробнее ](/docs/agents). -::: - -Режимы в opencode позволяют настраивать поведение, инструменты и подсказки для различных вариантов использования. - -Он имеет два встроенных режима: **сборка** и **планирование**. Вы можете настроить -эти или настройте свои собственные через конфигурацию opencode. - -Вы можете переключаться между режимами во время сеанса или настраивать их в своем файле конфигурации. - ---- - -## Встроенный - -opencode имеет два встроенных режима. - ---- - -### Build - -Build — это режим **по умолчанию** со всеми включенными инструментами. Это стандартный режим разработки, в котором вам необходим полный доступ к файловым операциям и системным командам. - ---- - -### Plan - -Ограниченный режим, предназначенный для планирования и анализа. В режиме Plan по умолчанию отключены следующие инструменты: - -- `write` – невозможно создавать новые файлы. -- `edit` – невозможно изменить существующие файлы, за исключением файлов, расположенных по адресу `.opencode/plans/*.md`, для детализации самого плана. -- `patch` – невозможно применить исправления. -- `bash` — невозможно выполнить shell-команды. - -Этот режим полезен, если вы хотите, чтобы ИИ анализировал код, предлагал изменения или создавал планы без внесения каких-либо фактических изменений в вашу кодовую базу. - ---- - -## Переключение - -Переключаться между режимами можно во время сеанса с помощью клавиши _Tab_. Или настроенную вами привязку клавиш `switch_mode`. - -См. также: [Formatters](/docs/formatters) для получения информации о конфигурации форматирования кода. - ---- - -## Настроить - -Вы можете настроить встроенные режимы или создать свои собственные посредством настройки. Режимы можно настроить двумя способами: - -### Конфигурация JSON - -Настройте режимы в файле конфигурации `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Конфигурация Markdown - -Вы также можете определить режимы, используя файлы Markdown. Поместите их в: - -- Глобальный: `~/.config/opencode/modes/` -- Проект: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Имя Markdown файла становится именем режима (например, `review.md` создает режим `review`). - -Давайте рассмотрим эти параметры конфигурации подробно. - ---- - -### Модель - -Используйте конфигурацию `model`, чтобы переопределить модель по умолчанию для этого режима. Полезно для использования разных моделей, оптимизированных под разные задачи. Например, более быстрая модель планирования и более эффективная модель реализации. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Температура - -Управляйте случайностью и креативностью ответов ИИ с помощью конфигурации `temperature`. Более низкие значения делают ответы более целенаправленными и детерминированными, а более высокие значения повышают креативность и вариативность. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Значения температуры обычно находятся в диапазоне от 0,0 до 1,0: - -- **0,0–0,2**: очень целенаправленные и детерминированные ответы, идеальные для анализа кода и планирования. -- **0,3–0,5**: сбалансированные ответы с некоторой креативностью, подходят для общих задач развития. -- **0,6–1,0**: более творческие и разнообразные ответы, полезные для мозгового штурма и исследования. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Если температура не указана, opencode использует значения по умолчанию для конкретной модели (обычно 0 для большинства моделей, 0,55 для моделей Qwen). - ---- - -### Промпт - -Укажите собственный файл системных подсказок для этого режима с помощью конфигурации `prompt`. Файл подсказки должен содержать инструкции, специфичные для целей режима. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Этот путь указан относительно того, где находится файл конфигурации. Так что это работает для -как глобальная конфигурация opencode, так и конфигурация конкретного проекта. - ---- - -### Инструменты - -Контролируйте, какие инструменты доступны в этом режиме, с помощью конфигурации `tools`. Вы можете включить или отключить определенные инструменты, установив для них значение `true` или `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Если инструменты не указаны, все инструменты включены по умолчанию. - ---- - -#### Доступные инструменты - -Вот всеми инструментами можно управлять через конфигурацию режима. - -| Инструмент | Описание | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Пользовательские режимы - -Вы можете создавать свои собственные режимы, добавляя их в конфигурацию. Вот примеры использования обоих подходов: - -### Использование конфигурации JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Использование файлов Markdown - -Создайте файлы режимов в `.opencode/modes/` для режимов, специфичных для проекта, или в `~/.config/opencode/modes/` для глобальных режимов: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Варианты использования - -Вот несколько распространенных случаев использования различных режимов. - -- **Режим сборки**: полная работа по разработке со всеми включенными инструментами. -- **Режим планирования**: анализ и планирование без внесения изменений. -- **Режим проверки**: проверка кода с доступом только для чтения и инструментами документирования. -- **Режим отладки**: сосредоточен на исследовании с включенными инструментами bash и чтения. -- **Режим документации**: запись документации с использованием файловых операций, но без системных команд. - -Вы также можете обнаружить, что разные модели подходят для разных случаев использования. diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx deleted file mode 100644 index 6cca309987a3..000000000000 --- a/packages/web/src/content/docs/th/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: โหมด -description: โหมดที่แตกต่างกันสำหรับกรณีการใช้งานที่แตกต่างกัน ---- - -:::caution -ขณะนี้โหมดได้รับการกำหนดค่าผ่านตัวเลือก `agent` ในการกำหนดค่า opencode ที่ -ตัวเลือก `mode` เลิกใช้แล้ว [เรียนรู้เพิ่มเติม](/docs/agents) -::: - -โหมดใน opencode ช่วยให้คุณปรับแต่งพฤติกรรม เครื่องมือ และพร้อมท์สำหรับกรณีการใช้งานที่แตกต่างกันได้ - -มาพร้อมกับโหมดในตัวสองโหมด: **สร้าง** และ **วางแผน** คุณสามารถปรับแต่งได้ -สิ่งเหล่านี้หรือกำหนดค่าของคุณเองผ่านการกำหนดค่า opencode - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันหรือกำหนดค่าในไฟล์กำหนดค่าของคุณ - ---- - -## บิวท์อิน - -opencode มาพร้อมกับโหมดในตัวสองโหมด - ---- - -### Build - -Build เป็นโหมด **ค่าเริ่มต้น** โดยที่เครื่องมือทั้งหมดเปิดใช้งานอยู่ นี่คือโหมดมาตรฐานสำหรับงานพัฒนาที่คุณต้องการสิทธิ์เข้าถึงการทำงานของไฟล์และคำสั่งระบบโดยสมบูรณ์ - ---- - -### Plan - -โหมดจำกัดที่ออกแบบมาเพื่อการวางแผนและการวิเคราะห์ ในโหมดแผน เครื่องมือต่อไปนี้จะถูกปิดใช้งานตามค่าเริ่มต้น: - -- `write` - ​​ไม่สามารถสร้างไฟล์ใหม่ได้ -- `edit` - ​​ไม่สามารถแก้ไขไฟล์ที่มีอยู่ได้ ยกเว้นไฟล์ที่อยู่ใน `.opencode/plans/*.md` เพื่อดูรายละเอียดแผนงาน -- `patch` - ​​ไม่สามารถใช้แพตช์ได้ -- `bash` - ​​ไม่สามารถรันคำสั่ง shell ได้ - -โหมดนี้มีประโยชน์เมื่อคุณต้องการให้ AI วิเคราะห์โค้ด แนะนำการเปลี่ยนแปลง หรือสร้างแผนโดยไม่ต้องทำการแก้ไขโค้ดเบสของคุณจริง ๆ - ---- - -## การสลับ - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันได้โดยใช้ปุ่ม _Tab_ หรือการเชื่อมโยงคีย์ `switch_mode` ที่คุณกำหนดค่าไว้ - -ดูเพิ่มเติมที่: [Formatters](/docs/formatters) สำหรับข้อมูลเกี่ยวกับการกำหนดค่าการจัดรูปแบบโค้ด - ---- - -## กำหนดค่า - -คุณสามารถปรับแต่งโหมดในตัวหรือสร้างโหมดของคุณเองผ่านการกำหนดค่าได้ โหมดสามารถกำหนดค่าได้สองวิธี: - -### การกำหนดค่าด้วย JSON - -กำหนดค่าโหมดในไฟล์กำหนดค่า `opencode.json` ของคุณ: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### การกำหนดค่า Markdown - -คุณยังสามารถกำหนดโหมดโดยใช้ไฟล์ Markdown ได้ วางไว้ใน: - -- ทั่วโลก: `~/.config/opencode/modes/` -- โครงการ: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -ชื่อไฟล์ Markdown จะกลายเป็นชื่อโหมด (เช่น `review.md` สร้างโหมด `review`) - -มาดูรายละเอียดตัวเลือกการกำหนดค่าเหล่านี้กัน - ---- - -### Model (โมเดล) - -ใช้การกำหนดค่า `model` เพื่อแทนที่โมเดลเริ่มต้นสำหรับโหมดนี้ มีประโยชน์สำหรับการใช้โมเดลที่แตกต่างกันซึ่งปรับให้เหมาะกับงานที่แตกต่างกัน ตัวอย่างเช่น โมเดลสำหรับการวางแผนที่เร็วขึ้น โมเดลที่มีความสามารถมากขึ้นสำหรับการนำไปปฏิบัติ - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature (อุณหภูมิ) - -ควบคุมการสุ่มและความคิดสร้างสรรค์ของการตอบสนองของ AI ด้วยการกำหนดค่า `temperature` ค่าที่ต่ำกว่าจะทำให้คำตอบมีจุดมุ่งหมายและกำหนดได้มากขึ้น ในขณะที่ค่าที่สูงกว่าจะเพิ่มความคิดสร้างสรรค์และความแปรปรวน - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -โดยทั่วไปค่าอุณหภูมิจะอยู่ในช่วงตั้งแต่ 0.0 ถึง 1.0: - -- **0.0-0.2**: การตอบสนองที่มุ่งเน้นและกำหนดไว้อย่างมาก เหมาะสำหรับการวิเคราะห์และวางแผนโค้ด -- **0.3-0.5**: การตอบสนองที่สมดุลและความคิดสร้างสรรค์บางส่วน เหมาะสำหรับงานพัฒนาทั่วไป -- **0.6-1.0**: คำตอบที่สร้างสรรค์และหลากหลายยิ่งขึ้น ซึ่งมีประโยชน์สำหรับการระดมความคิดและการสำรวจ - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -หากไม่มีการระบุอุณหภูมิ opencode จะใช้ค่าเริ่มต้นเฉพาะรุ่น (โดยทั่วไปจะเป็น 0 สำหรับรุ่นส่วนใหญ่ และ 0.55 สำหรับรุ่น Qwen) - ---- - -### พรอมต์ - -ระบุไฟล์พรอมต์ระบบที่กำหนดเองสำหรับโหมดนี้ด้วยการกำหนดค่า `prompt` ไฟล์พร้อมท์ควรมีคำแนะนำเฉพาะสำหรับวัตถุประสงค์ของโหมด - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -เส้นทางนี้สัมพันธ์กับตำแหน่งของไฟล์กำหนดค่า ดังนั้นสิ่งนี้จึงใช้ได้กับ -ทั้งการกำหนดค่า opencode ส่วนกลางและการกำหนดค่าเฉพาะโครงการ - ---- - -### เครื่องมือ - -ควบคุมว่าเครื่องมือใดบ้างที่พร้อมใช้งานในโหมดนี้ด้วยการกำหนดค่า `tools` คุณสามารถเปิดหรือปิดใช้งานเครื่องมือเฉพาะได้โดยตั้งค่าเป็น `true` หรือ `false` - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -หากไม่มีการระบุเครื่องมือ เครื่องมือทั้งหมดจะถูกเปิดใช้งานตามค่าเริ่มต้น - ---- - -#### เครื่องมือที่มีอยู่ - -นี่คือเครื่องมือทั้งหมดที่สามารถควบคุมได้ผ่านการกำหนดค่าโหมด - -| เครื่องมือ | คำอธิบาย | -| ----------- | ------------------------- | -| `bash` | ดำเนินการคำสั่ง shell | -| `edit` | แก้ไขไฟล์ที่มีอยู่ | -| `write` | สร้างไฟล์ใหม่ | -| `read` | อ่านเนื้อหาไฟล์ | -| `grep` | ค้นหาเนื้อหาไฟล์ | -| `glob` | ค้นหาไฟล์ตามรูปแบบ | -| `patch` | ใช้แพทช์กับไฟล์ | -| `todowrite` | จัดการรายการสิ่งที่ต้องทำ | -| `webfetch` | ดึงเนื้อหาเว็บ | - ---- - -## โหมดกำหนดเอง - -คุณสามารถสร้างโหมดที่คุณกำหนดเองได้โดยเพิ่มเข้าไปในการกำหนดค่า นี่คือตัวอย่างที่ใช้ทั้งสองวิธี: - -### ใช้การกำหนดค่า JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### การใช้ไฟล์ Markdown - -สร้างไฟล์โหมดใน `.opencode/modes/` สำหรับโหมดเฉพาะโครงการหรือ `~/.config/opencode/modes/` สำหรับโหมดทั่วโลก: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### กรณีการใช้งาน - -ต่อไปนี้เป็นกรณีการใช้งานทั่วไปสำหรับโหมดต่างๆ - -- **Build mode**: งานพัฒนาเต็มรูปแบบโดยเปิดใช้งานเครื่องมือทั้งหมด -- **Plan mode**: วิเคราะห์และวางแผนโดยไม่ทำการเปลี่ยนแปลง -- **Review mode**: การตรวจสอบโค้ดพร้อมการเข้าถึงแบบอ่านอย่างเดียวพร้อมเครื่องมือเอกสารประกอบ -- **Debug mode**: มุ่งเน้นไปที่การตรวจสอบโดยเปิดใช้งานเครื่องมือ bash และอ่าน -- **Docs mode**: การเขียนเอกสารด้วยการทำงานของไฟล์ แต่ไม่มีคำสั่งระบบ - -คุณอาจพบว่ารุ่นต่างๆ นั้นดีสำหรับกรณีการใช้งานที่แตกต่างกัน diff --git a/packages/web/src/content/docs/tr/modes.mdx b/packages/web/src/content/docs/tr/modes.mdx deleted file mode 100644 index 8f722ec228c0..000000000000 --- a/packages/web/src/content/docs/tr/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modlar -description: farklı kullanım durumları için farklı modlar. ---- - -:::caution -Modlar artık opencode yapılandırmasındaki `agent` seçeneği aracılığıyla yapılandırılıyor. -`mode` seçeneği artık kullanımdan kaldırıldı. [Daha fazla bilgi](/docs/agents). -::: - -opencode'daki modlar, farklı kullanım durumları için davranışı, araçları ve istemleri özelleştirmenize olanak tanır. - -İki yerleşik modla birlikte gelir: **build** ve **plan**. Kişiselleştirebilirsiniz -bunları veya opencode yapılandırması aracılığıyla kendinizinkini yapılandırın. - -Bir oturum sırasında modlar arasında geçiş yapabilir veya bunları yapılandırma dosyanızda yapılandırabilirsiniz. - ---- - -## Yerleşik - -opencode iki yerleşik modla birlikte gelir. - ---- - -### Build - -Derleme, tüm araçların etkin olduğu **varsayılan** moddur. Bu, dosya işlemlerine ve sistem komutlarına tam erişime ihtiyaç duyduğunuz geliştirme çalışmaları için standart moddur. - ---- - -### Plan - -Planlama ve analiz için tasarlanmış sınırlı bir mod. Plan modunda aşağıdaki araçlar varsayılan olarak devre dışıdır: - -- `write` - Yeni dosyalar oluşturulamıyor -- `edit` - Planın kendisini detaylandırmak için `.opencode/plans/*.md` adresinde bulunan dosyalar dışında mevcut dosyalar değiştirilemez -- `patch` - Yamalar uygulanamaz -- `bash` - Kabuk komutları yürütülemiyor - -Bu mod, yapay zekanın kod tabanınızda herhangi bir gerçek değişiklik yapmadan kodu analiz etmesini, değişiklik önermesini veya plan oluşturmasını istediğinizde kullanışlıdır. - ---- - -## Geçiş - -Bir oturum sırasında _Tab_ tuşunu kullanarak modlar arasında geçiş yapabilirsiniz. Veya yapılandırılmış `switch_mode` tuş bağlantınız. - -Ayrıca bkz.: Kod biçimlendirme yapılandırması hakkında bilgi için [Formatters](/docs/formatters). - ---- - -## Yapılandırma - -Yerleşik modları özelleştirebilir veya yapılandırma aracılığıyla kendinizinkini oluşturabilirsiniz. Modlar iki şekilde yapılandırılabilir: - -### JSON Yapılandırması - -`opencode.json` yapılandırma dosyanızdaki modları yapılandırın: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Yapılandırması - -Markdown dosyalarını kullanarak modları da tanımlayabilirsiniz. Bunları şuraya yerleştirin: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown dosyası adı mod adı olur (örneğin, `review.md` bir `review` modu oluşturur). - -Bu yapılandırma seçeneklerine ayrıntılı olarak bakalım. - ---- - -### Model - -Bu modun varsayılan modelini geçersiz kılmak için `model` ayarını kullanın. Farklı bölümler için optimize edilmiş farklı modelleri kullanmak için kullanışlıdır. Örneğin planlama için daha hızlı bir model, uygulama için daha yetenekli bir model. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Sıcaklık - -`temperature` yapılandırmasıyla yapay zekanın yanıtlarının rastgeleliğini ve yaratıcılığını kontrol edin. Düşük değerler yanıtları daha odaklı ve belirleyici hale getirirken, yüksek değerler yaratıcılığı ve değişkenliği artırır. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Sıcaklık değerleri tipik olarak 0,0 ila 1,0 arasındadır: - -- **0,0-0,2**: Çok odaklı ve belirleyici yanıtlar, kod analizi ve planlaması için idealdir -- **0,3-0,5**: Biraz yaratıcılık içeren dengeli yanıtlar, genel gelişim görevleri için iyi -- **0,6-1,0**: Daha yaratıcı ve çeşitli yanıtlar, beyin fırtınası ve keşif için yararlı - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Sıcaklık belirtilmezse, opencode modele özgü varsayılanları kullanır (çoğu model için genellikle 0, Qwen modelleri için 0,55). - ---- - -### İstem - -`prompt` yapılandırmasıyla bu mod için özel bir sistem bilgi istemi dosyası belirtin. Bilgi istemi dosyası, modun amacına özel talimatlar içermelidir. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Bu yol, yapılandırma dosyasının bulunduğu yere göredir. Yani bu işe yarıyor -hem global opencode yapılandırması hem de projeye özel yapılandırma. - ---- - -### Araçlar - -`tools` yapılandırmasıyla bu modda hangi araçların kullanılabileceğini kontrol edin. Belirli araçları `true` veya `false` olarak ayarlayarak etkinleştirebilir veya devre dışı bırakabilirsiniz. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hiçbir araç belirtilmezse tüm araçlar varsayılan olarak etkindir. - ---- - -#### Mevcut araçlar - -İşte mod yapılandırması aracılığıyla kontrol edilebilecek tüm araçlar. - -| Araç | Açıklama | -| ----------- | ------------------------------ | -| `bash` | Kabuk komutlarını yürütün | -| `edit` | Mevcut dosyaları değiştirin | -| `write` | Yeni dosyalar oluştur | -| `read` | Dosya içeriğini oku | -| `grep` | Dosya içeriğini ara | -| `glob` | Dosyaları desene göre bul | -| `patch` | Dosyalara yama uygula | -| `todowrite` | Yapılacaklar listelerini yönet | -| `webfetch` | Web içeriğini getir | - ---- - -## Özel modlar - -Yapılandırmaya ekleyerek kendi özel modlarınızı oluşturabilirsiniz. Her iki yaklaşımın kullanıldığı örnekler aşağıda verilmiştir: - -### JSON yapılandırması kullanma - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown dosyaları kullanma - -Projeye özel modlar için `.opencode/modes/` veya genel modlar için `~/.config/opencode/modes/`'de mod dosyaları oluşturun: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Kullanım Senaryoları - -Farklı modlar için bazı yaygın kullanım senaryoları aşağıda verilmiştir. - -- **Derleme modu**: Tüm araçların etkinleştirildiği tam geliştirme çalışması -- **Plan modu**: Değişiklik yapmadan analiz ve planlama -- **İnceleme modu**: Salt okunur erişim ve belgeleme araçlarıyla kod incelemesi -- **Hata ayıklama modu**: Bash ve okuma araçları etkinken araştırmaya odaklanıldı -- **Belgeler modu**: Dosya işlemleriyle ancak sistem komutları olmadan belge yazma - -Ayrıca farklı modellerin farklı kullanım durumları için iyi olduğunu da görebilirsiniz. diff --git a/packages/web/src/content/docs/zh-cn/modes.mdx b/packages/web/src/content/docs/zh-cn/modes.mdx deleted file mode 100644 index 256cffe8119c..000000000000 --- a/packages/web/src/content/docs/zh-cn/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式适用于不同的使用场景。 ---- - -:::caution -模式现在通过 opencode 配置中的 `agent` 选项进行配置。`mode` 选项已废弃。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允许你为不同的使用场景自定义行为、工具和提示词。 - -opencode 自带两种内置模式:**build** 和 **plan**。你可以自定义这些模式,也可以通过 opencode 配置创建自己的模式。 - -你可以在会话中切换模式,也可以在配置文件中进行配置。 - ---- - -## 内置模式 - -opencode 自带两种内置模式。 - ---- - -### Build - -Build 是启用了所有工具的**默认**模式。这是进行开发工作的标准模式,你可以完全访问文件操作和系统命令。 - ---- - -### Plan - -Plan 是一种为规划和分析设计的受限模式。在 plan 模式下,以下工具默认被禁用: - -- `write` - 无法创建新文件 -- `edit` - 无法修改现有文件,但位于 `.opencode/plans/*.md` 的文件除外,用于详细说明计划本身 -- `patch` - 无法应用补丁 -- `bash` - 无法执行 shell 命令 - -当你希望 AI 分析代码、提出修改建议或制定计划,而不对代码库进行任何实际更改时,此模式非常有用。 - ---- - -## 切换 - -你可以在会话中使用 _Tab_ 键切换模式,或者使用你配置的 `switch_mode` 快捷键。 - -另请参阅:[格式化工具](/docs/formatters)了解代码格式化配置的相关信息。 - ---- - -## 配置 - -你可以自定义内置模式或通过配置创建自己的模式。模式可以通过两种方式进行配置: - -### JSON 配置 - -在 `opencode.json` 配置文件中配置模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 配置 - -你还可以使用 Markdown 文件定义模式。将文件放置在以下位置: - -- 全局:`~/.config/opencode/modes/` -- 项目:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 文件名即为模式名称(例如,`review.md` 创建一个名为 `review` 的模式)。 - -下面让我们详细了解这些配置选项。 - ---- - -### 模型 - -使用 `model` 配置可以覆盖该模式的默认模型。这对于针对不同任务使用不同模型非常有用。例如,规划时使用更快的模型,实现时使用更强大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -使用 `temperature` 配置控制 AI 响应的随机性和创造性。较低的值使响应更加集中和确定性,较高的值则增加创造性和多样性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -温度值的范围通常为 0.0 到 1.0: - -- **0.0-0.2**:非常集中且确定性高的响应,适合代码分析和规划 -- **0.3-0.5**:兼顾稳定性与创造力的平衡型响应,适合一般开发任务 -- **0.6-1.0**:更具创造性和多样性的响应,适合头脑风暴和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定温度,opencode 将使用模型特定的默认值(大多数模型通常为 0,Qwen 模型为 0.55)。 - ---- - -### 提示词 - -使用 `prompt` 配置为模式指定自定义系统提示词文件。提示词文件应包含针对该模式用途的具体指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路径相对于配置文件所在位置。因此,全局 opencode 配置和项目特定配置均可使用。 - ---- - -### 工具 - -使用 `tools` 配置控制该模式下可用的工具。你可以将特定工具设置为 `true` 或 `false` 来启用或禁用它们。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,则默认启用所有工具。 - ---- - -#### 可用工具 - -以下是所有可通过模式配置控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 执行 shell 命令 | -| `edit` | 修改现有文件 | -| `write` | 创建新文件 | -| `read` | 读取文件内容 | -| `grep` | 搜索文件内容 | -| `glob` | 按模式查找文件 | -| `patch` | 对文件应用补丁 | -| `todowrite` | 管理待办事项列表 | -| `webfetch` | 获取网页内容 | - ---- - -## 自定义模式 - -你可以通过在配置中添加自定义模式来创建自己的模式。以下是两种方式的示例: - -### 使用 JSON 配置 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 文件 - -在 `.opencode/modes/` 中创建项目特定的模式文件,或在 `~/.config/opencode/modes/` 中创建全局模式文件: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用场景 - -以下是不同模式的一些常见使用场景。 - -- **Build 模式**:启用所有工具的完整开发工作 -- **Plan 模式**:分析和规划,不做任何更改 -- **Review 模式**:使用只读访问权限加文档工具进行代码审查 -- **Debug 模式**:启用 bash 和读取工具,专注于问题排查 -- **Docs 模式**:支持文件操作但不支持系统命令的文档编写 - -你可能还会发现不同的模型适用于不同的使用场景。 diff --git a/packages/web/src/content/docs/zh-tw/modes.mdx b/packages/web/src/content/docs/zh-tw/modes.mdx deleted file mode 100644 index 625b4d0219f3..000000000000 --- a/packages/web/src/content/docs/zh-tw/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式適用於不同的使用情境。 ---- - -:::caution -模式現在透過 opencode 設定中的 `agent` 選項進行設定。`mode` 選項已廢棄。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允許您為不同的使用情境自訂行為、工具和提示詞。 - -opencode 自帶兩種內建模式:**build** 和 **plan**。您可以自訂這些模式,也可以透過 opencode 設定建立自己的模式。 - -您可以在工作階段中切換模式,也可以在設定檔中進行設定。 - ---- - -## 內建模式 - -opencode 自帶兩種內建模式。 - ---- - -### Build - -Build 是啟用了所有工具的**預設**模式。這是進行開發工作的標準模式,您可以完全存取檔案操作和系統指令。 - ---- - -### Plan - -Plan 是一種為規劃和分析設計的受限模式。在 plan 模式下,以下工具預設被停用: - -- `write` - 無法建立新檔案 -- `edit` - 無法修改現有檔案,但位於 `.opencode/plans/*.md` 的檔案除外,用於詳細說明計畫本身 -- `patch` - 無法套用補丁 -- `bash` - 無法執行 shell 指令 - -當您希望 AI 分析程式碼、提出修改建議或制定計畫,而不對程式碼庫進行任何實際更改時,此模式非常有用。 - ---- - -## 切換 - -您可以在工作階段中使用 _Tab_ 鍵切換模式,或者使用您設定的 `switch_mode` 快捷鍵。 - -另請參閱:[格式化工具](/docs/formatters)了解程式碼格式化設定的相關資訊。 - ---- - -## 設定 - -您可以自訂內建模式或透過設定建立自己的模式。模式可以透過兩種方式進行設定: - -### JSON 設定 - -在 `opencode.json` 設定檔中設定模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -您還可以使用 Markdown 檔案定義模式。將檔案放置在以下位置: - -- 全域:`~/.config/opencode/modes/` -- 專案:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 檔案名稱即為模式名稱(例如,`review.md` 建立一個名為 `review` 的模式)。 - -下面讓我們詳細了解這些設定選項。 - ---- - -### 模型 - -使用 `model` 設定可以覆寫該模式的預設模型。這對於針對不同任務使用不同模型非常有用。例如,規劃時使用更快的模型,實作時使用更強大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 溫度 - -使用 `temperature` 設定控制 AI 回應的隨機性和創造性。較低的值使回應更加集中和確定性,較高的值則增加創造性和多樣性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -溫度值的範圍通常為 0.0 到 1.0: - -- **0.0-0.2**:非常集中且確定性高的回應,適合程式碼分析和規劃 -- **0.3-0.5**:兼顧穩定性與創造力的平衡型回應,適合一般開發任務 -- **0.6-1.0**:更具創造性和多樣性的回應,適合腦力激盪和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定溫度,opencode 將使用模型特定的預設值(大多數模型通常為 0,Qwen 模型為 0.55)。 - ---- - -### 提示詞 - -使用 `prompt` 設定為模式指定自訂系統提示詞檔案。提示詞檔案應包含針對該模式用途的具體指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路徑相對於設定檔所在位置。因此,全域 opencode 設定和專案特定設定均可使用。 - ---- - -### 工具 - -使用 `tools` 設定控制該模式下可用的工具。您可以將特定工具設定為 `true` 或 `false` 來啟用或停用它們。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,則預設啟用所有工具。 - ---- - -#### 可用工具 - -以下是所有可透過模式設定控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 執行 shell 指令 | -| `edit` | 修改現有檔案 | -| `write` | 建立新檔案 | -| `read` | 讀取檔案內容 | -| `grep` | 搜尋檔案內容 | -| `glob` | 按模式尋找檔案 | -| `patch` | 對檔案套用補丁 | -| `todowrite` | 管理待辦事項清單 | -| `webfetch` | 擷取網頁內容 | - ---- - -## 自訂模式 - -您可以透過在設定中新增自訂模式來建立自己的模式。以下是兩種方式的範例: - -### 使用 JSON 設定 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 檔案 - -在 `.opencode/modes/` 中建立專案特定的模式檔案,或在 `~/.config/opencode/modes/` 中建立全域模式檔案: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用情境 - -以下是不同模式的一些常見使用情境。 - -- **Build 模式**:啟用所有工具的完整開發工作 -- **Plan 模式**:分析和規劃,不做任何更改 -- **Review 模式**:使用唯讀存取權限加文件工具進行程式碼審查 -- **Debug 模式**:啟用 bash 和讀取工具,專注於問題排查 -- **Docs 模式**:支援檔案操作但不支援系統指令的文件編寫 - -您可能還會發現不同的模型適用於不同的使用情境。 From 163290bcf08eacaf8d62330b8fff92cf0701eab5 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 1 May 2026 11:56:31 +0800 Subject: [PATCH 0106/1114] desktop: sentry integration (#15300) Co-authored-by: Jay V --- .github/workflows/deploy.yml | 6 + .github/workflows/publish.yml | 7 ++ bun.lock | 114 ++++++++++++++++-- package.json | 2 + packages/app/package.json | 2 + packages/app/src/app.tsx | 20 ++- packages/app/src/entry.tsx | 20 +++ packages/app/src/env.d.ts | 4 + packages/app/src/i18n/ar.ts | 2 + packages/app/src/i18n/br.ts | 2 + packages/app/src/i18n/bs.ts | 2 + packages/app/src/i18n/da.ts | 2 + packages/app/src/i18n/de.ts | 2 + packages/app/src/i18n/en.ts | 2 + packages/app/src/i18n/es.ts | 2 + packages/app/src/i18n/fr.ts | 2 + packages/app/src/i18n/ja.ts | 2 + packages/app/src/i18n/ko.ts | 2 + packages/app/src/i18n/no.ts | 2 + packages/app/src/i18n/pl.ts | 2 + packages/app/src/i18n/ru.ts | 2 + packages/app/src/i18n/th.ts | 2 + packages/app/src/i18n/tr.ts | 2 + packages/app/src/i18n/zh.ts | 2 + packages/app/src/i18n/zht.ts | 2 + packages/app/src/pages/error.tsx | 22 +++- packages/app/vite.config.ts | 22 +++- .../desktop-electron/electron.vite.config.ts | 21 +++- packages/desktop-electron/package.json | 2 + .../desktop-electron/src/renderer/index.tsx | 20 +++ packages/desktop/package.json | 2 + packages/desktop/src/env.d.ts | 9 ++ packages/desktop/src/index.tsx | 1 + packages/desktop/vite.config.ts | 6 +- 34 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 packages/desktop/src/env.d.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 96f437a73fca..e346d0cd5c69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,9 @@ jobs: PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd9b60f8bd3b..9981edad7f3f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -494,6 +494,13 @@ jobs: working-directory: packages/desktop-electron env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Package and publish if: needs.version.outputs.release diff --git a/bun.lock b/bun.lock index 093bb880a480..a771dd5295e5 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -69,6 +70,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -230,6 +232,7 @@ "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:", @@ -250,6 +253,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -275,6 +279,8 @@ "@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:", @@ -688,6 +694,8 @@ "@opentui/solid": "0.2.0", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -1956,6 +1964,44 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="], + + "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="], + + "@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="], + + "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="], + + "@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="], + + "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="], + + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="], + + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="], + + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="], + + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="], + + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="], + + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="], + + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="], + + "@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="], + + "@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="], + + "@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="], + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], @@ -3240,7 +3286,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3734,7 +3780,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4138,7 +4184,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -4188,7 +4234,7 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -4948,7 +4994,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], @@ -5056,7 +5102,9 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webpack-sources": ["webpack-sources@3.4.0", "", {}, "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -5608,6 +5656,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -5662,6 +5720,8 @@ "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "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-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -5846,8 +5906,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -5958,7 +6016,7 @@ "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -5970,6 +6028,8 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6066,6 +6126,10 @@ "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=="], + + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "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=="], @@ -6566,6 +6630,16 @@ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6588,6 +6662,8 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -6734,8 +6810,12 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -6764,6 +6844,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -6988,6 +7070,12 @@ "@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=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7084,6 +7172,8 @@ "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], @@ -7096,6 +7186,8 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7150,6 +7242,8 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -7176,6 +7270,8 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 975f1777439c..9a0113030cb0 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" diff --git a/packages/app/package.json b/packages/app/package.json index 1a5a1a007fe5..c6a4f43981f4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -40,6 +41,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@sentry/solid": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/core": "workspace:*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bf8138fcdeae..3189d80257df 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,4 +1,5 @@ import "@/index.css" +import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -148,12 +149,19 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - }> - - - {props.children} - - + { + Sentry.captureException(error) + return + }} + > + + + + {props.children} + + + diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d3..ade572c2fd50 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,5 +1,6 @@ // @refresh reload +import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" @@ -125,6 +126,25 @@ const platform: Platform = { setDefaultServer: writeDefaultServerUrl, } +if (import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`, + initialScope: { + tags: { + platform: "web", + }, + }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + if (root instanceof HTMLElement) { const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } render( diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 9b03d336f9d4..39f827eb64a9 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -2,6 +2,10 @@ interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 49808f3fbbe3..405153647a91 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "حدث خطأ أثناء تحميل التطبيق.", "error.page.details.label": "تفاصيل الخطأ", "error.page.action.restart": "إعادة تشغيل", + "error.page.action.report": "الإبلاغ عن الخطأ", + "error.page.action.reported": "تم الإبلاغ عن الخطأ", "error.page.action.checking": "جارٍ التحقق...", "error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.updateTo": "تحديث إلى {{version}}", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5b96ff57a659..9a3a4768029d 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.details.label": "Detalhes do Erro", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Reportar erro", + "error.page.action.reported": "Erro reportado", "error.page.action.checking": "Verificando...", "error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.updateTo": "Atualizar para {{version}}", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 98e565464be2..ae34749c3201 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.details.label": "Detalji greške", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Prijavi grešku", + "error.page.action.reported": "Greška prijavljena", "error.page.action.checking": "Provjera...", "error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.updateTo": "Ažuriraj na {{version}}", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 5c1dcfcae409..370420398bbd 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -446,6 +446,8 @@ export const dict = { "error.page.description": "Der opstod en fejl under indlæsning af applikationen.", "error.page.details.label": "Fejldetaljer", "error.page.action.restart": "Genstart", + "error.page.action.report": "Rapportér fejl", + "error.page.action.reported": "Fejl rapporteret", "error.page.action.checking": "Tjekker...", "error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.updateTo": "Opdater til {{version}}", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 68a4def36ada..532ff7c08b3a 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -410,6 +410,8 @@ export const dict = { "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", "error.page.details.label": "Fehlerdetails", "error.page.action.restart": "Neustart", + "error.page.action.report": "Fehler melden", + "error.page.action.reported": "Fehler gemeldet", "error.page.action.checking": "Prüfen...", "error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.updateTo": "Auf {{version}} aktualisieren", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1d2b03db76d3..250d26edbe97 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -465,6 +465,8 @@ export const dict = { "error.page.description": "An error occurred while loading the application.", "error.page.details.label": "Error Details", "error.page.action.restart": "Restart", + "error.page.action.report": "Report Error", + "error.page.action.reported": "Error Reported", "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4761cd2000b2..cde8f3ec5e86 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.details.label": "Detalles del error", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Informar error", + "error.page.action.reported": "Error informado", "error.page.action.checking": "Comprobando...", "error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.updateTo": "Actualizar a {{version}}", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f470cace42ec..6cea948b1c3a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -406,6 +406,8 @@ export const dict = { "error.page.description": "Une erreur s'est produite lors du chargement de l'application.", "error.page.details.label": "Détails de l'erreur", "error.page.action.restart": "Redémarrer", + "error.page.action.report": "Signaler l'erreur", + "error.page.action.reported": "Erreur signalée", "error.page.action.checking": "Vérification...", "error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.updateTo": "Mettre à jour vers {{version}}", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 51eab3d09b76..ec4d95b98e99 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", "error.page.details.label": "エラー詳細", "error.page.action.restart": "再起動", + "error.page.action.report": "エラーを報告", + "error.page.action.reported": "エラーを報告しました", "error.page.action.checking": "確認中...", "error.page.action.checkUpdates": "アップデートを確認", "error.page.action.updateTo": "{{version}}にアップデート", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 206ae23d82ea..6eb064244b88 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -401,6 +401,8 @@ export const dict = { "error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.", "error.page.details.label": "오류 세부 정보", "error.page.action.restart": "다시 시작", + "error.page.action.report": "오류 신고", + "error.page.action.reported": "오류가 신고됨", "error.page.action.checking": "확인 중...", "error.page.action.checkUpdates": "업데이트 확인", "error.page.action.updateTo": "{{version}} 버전으로 업데이트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3014e1e5d304..a280a3dbf16b 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -450,6 +450,8 @@ export const dict = { "error.page.description": "Det oppstod en feil under lasting av applikasjonen.", "error.page.details.label": "Feildetaljer", "error.page.action.restart": "Start på nytt", + "error.page.action.report": "Rapporter feil", + "error.page.action.reported": "Feil rapportert", "error.page.action.checking": "Sjekker...", "error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.updateTo": "Oppdater til {{version}}", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index a555f09e3514..eea33b72342b 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", "error.page.details.label": "Szczegóły błędu", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Zgłoś błąd", + "error.page.action.reported": "Błąd zgłoszony", "error.page.action.checking": "Sprawdzanie...", "error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.updateTo": "Zaktualizuj do {{version}}", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d8a81357b643..6e455220bef0 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -448,6 +448,8 @@ export const dict = { "error.page.description": "Произошла ошибка при загрузке приложения.", "error.page.details.label": "Детали ошибки", "error.page.action.restart": "Перезапустить", + "error.page.action.report": "Сообщить об ошибке", + "error.page.action.reported": "Об ошибке сообщено", "error.page.action.checking": "Проверка...", "error.page.action.checkUpdates": "Проверить обновления", "error.page.action.updateTo": "Обновить до {{version}}", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b51b9a13c330..998962db9857 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -447,6 +447,8 @@ export const dict = { "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", "error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.action.restart": "รีสตาร์ท", + "error.page.action.report": "รายงานข้อผิดพลาด", + "error.page.action.reported": "รายงานข้อผิดพลาดแล้ว", "error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.updateTo": "อัปเดตเป็น {{version}}", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 0284e908b6c3..4d006a43b122 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "Uygulama yüklenirken bir hata oluştu.", "error.page.details.label": "Hata Detayları", "error.page.action.restart": "Yeniden Başlat", + "error.page.action.report": "Hatayı Bildir", + "error.page.action.reported": "Hata Bildirildi", "error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.updateTo": "{{version}} sürümüne güncelle", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e9d0fa4715b6..a422a5d61dd1 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "加载应用程序时发生错误。", "error.page.details.label": "错误详情", "error.page.action.restart": "重启", + "error.page.action.report": "上报错误", + "error.page.action.reported": "错误已上报", "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index ff6392d25981..7d894a83e0e1 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -445,6 +445,8 @@ export const dict = { "error.page.description": "載入應用程式時發生錯誤。", "error.page.details.label": "錯誤詳情", "error.page.action.restart": "重新啟動", + "error.page.action.report": "回報錯誤", + "error.page.action.reported": "已回報錯誤", "error.page.action.checking": "檢查中...", "error.page.action.checkUpdates": "檢查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index ba0045ec934d..5f3d7baa6801 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,7 +1,8 @@ import { TextField } from "@opencode-ai/ui/text-field" +import * as Sentry from "@sentry/solid" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, Show } from "solid-js" +import { Component, createSignal, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" @@ -270,10 +271,27 @@ export const ErrorPage: Component = (props) => { label={language.t("error.page.details.label")} hideLabel /> -
+
+ + {(_) => { + const [reported, setReported] = createSignal(false) + return ( + + ) + }} + { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + void initI18n() const deepLinkEvent = "opencode:deep-link" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ff81acaaf34b..61ef5d05c380 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", @@ -35,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts new file mode 100644 index 000000000000..aff0168422da --- /dev/null +++ b/packages/desktop/src/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index a760cb4091f0..1a0da014dd43 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 62c3a099adeb..e8f8f8465d05 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -15,9 +15,9 @@ export default defineConfig({ // Improves production stack traces keepNames: true, }, - // build: { - // sourcemap: true, - // }, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, From 4e451a4b0f71b8f0a22cbbc7cfdc7825a92915e8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 04:07:58 +0000 Subject: [PATCH 0107/1114] 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 d9169d95c381..b9ba578ac652 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QthnHaV7hTxvxEzQpxI7HWjb2n1ZMTYviA7DDdAzJwk=", - "aarch64-linux": "sha256-l6/2wVmNBBtCI0ovhfhyq3ZSebj6qZFWXptYqUy2Rh8=", - "aarch64-darwin": "sha256-upNQqcwa/b/XPyQFTMOkp6S5QvILg5Y3LnDCmEe9iqA=", - "x86_64-darwin": "sha256-/M91h4YUTsBveDhwtl5mTkQaRE+92WZmzs2p4D+RuBk=" + "x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=", + "aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=", + "aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=", + "x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY=" } } From 08895c396e76122d7b5d5d781542565371473b46 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:20:54 -0500 Subject: [PATCH 0108/1114] docs: fix tui and keybinds documentation (#25233) --- packages/web/src/content/docs/keybinds.mdx | 9 ++++++++- packages/web/src/content/docs/tui.mdx | 12 ++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 5488aaf81cbe..4106cb232603 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -23,7 +23,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_list": "l", "session_timeline": "g", "session_fork": "none", - "session_rename": "none", + "session_rename": "ctrl+r", "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", @@ -105,6 +105,13 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. } ``` +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: + +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. +::: + --- ## Leader key diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index e89fb4af39ef..73ecce93b578 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -99,8 +99,6 @@ Toggle tool execution details. /details ``` -**Keybind:** `ctrl+x d` - --- ### editor @@ -147,8 +145,6 @@ Show the help dialog. /help ``` -**Keybind:** `ctrl+x h` - --- ### init @@ -159,8 +155,6 @@ Guided setup for creating or updating `AGENTS.md`. [Learn more](/docs/rules). /init ``` -**Keybind:** `ctrl+x i` - --- ### models @@ -226,8 +220,6 @@ Share current session. [Learn more](/docs/share). /share ``` -**Keybind:** `ctrl+x s` - --- ### themes @@ -366,7 +358,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). }, "scroll_speed": 3, "scroll_acceleration": { - "enabled": true + "enabled": false }, "diff_style": "auto", "mouse": true @@ -390,7 +382,7 @@ Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. ## Customization -You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts. +You can customize various aspects of the TUI view using the command palette (`ctrl+p`). These settings persist across restarts. --- From 4eae8ec037356a587521c7bc38cd1416f7d92cdc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 04:21:51 +0000 Subject: [PATCH 0109/1114] chore: generate --- packages/web/src/content/docs/keybinds.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 4106cb232603..86970638c71e 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -110,7 +110,7 @@ On Windows, the defaults for `input_undo` and `terminal_suspend` are different: - `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). - `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. -::: + ::: --- From 563177c6ac738e5899a1d401f578e840a16a32ad Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 00:13:03 -0500 Subject: [PATCH 0110/1114] fix: fix issue if tool returned image and empty text and it caused api errors (#25241) --- packages/opencode/src/session/message-v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 911f58efd0b9..a017ead1e631 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -772,7 +772,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "content", value: [ - { type: "text", text: outputObject.text }, + ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, From a5aa72bd7d6767ae491f603ac4ba19a48b099bd1 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 1 May 2026 13:39:22 +0800 Subject: [PATCH 0111/1114] fix: update provider store after loading providers in bootstrap (#25236) --- packages/app/src/context/global-sync.tsx | 3 +++ packages/app/src/context/global-sync/bootstrap.ts | 3 --- .../app/src/context/global-sync/child-store.test.ts | 1 + packages/app/src/context/global-sync/child-store.ts | 13 +++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ba9f6d52ab80..6190deb1ee4d 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -204,6 +204,9 @@ function createGlobalSync() { }, translate: language.t, getSdk: sdkFor, + global: { + provider: globalStore.provider, + }, }) async function loadSessions(directory: string) { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 451531835dec..e85516bf14bf 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -260,9 +260,6 @@ export async function bootstrapDirectory(input: { const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined if (seededProject) input.setStore("project", seededProject) if (seededPath) input.setStore("path", seededPath) - if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { - input.setStore("provider", input.global.provider) - } if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 24b4a465002d..30dda86919b6 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -23,6 +23,7 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, getSdk: () => null!, + global: { provider: null! }, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index a7209d3dbdcd..0138310cdccd 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -27,6 +27,9 @@ export function createChildStoreManager(input: { onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string getSdk: (directory: string) => OpencodeClient + global: { + provider: ProviderListResponse + } }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -189,7 +192,13 @@ export function createChildStoreManager(input: { get provider_ready() { return !providerQuery.isLoading }, - provider: { all: [], connected: [], default: {} }, + get provider() { + const EMPTY = { all: [], connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0) + return input.global.provider + return providerQuery.data ?? EMPTY + }, config: {}, get path() { if (pathQuery.isLoading || !pathQuery.data) From 21f8027ef74ee281cd9e94ddfd4fb96947fc1f6b Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 1 May 2026 06:13:48 +0000 Subject: [PATCH 0112/1114] sync release versions for v1.14.31 --- 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 a771dd5295e5..fcd8e94431a8 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.30", + "version": "1.14.31", "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.30", + "version": "1.14.31", "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.30", + "version": "1.14.31", "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.30", + "version": "1.14.31", "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.30", + "version": "1.14.31", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.30", + "version": "1.14.31", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.31", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.30", + "version": "1.14.31", "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 c6a4f43981f4..2decf1fce4de 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.31", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 49b4cf48cf77..afb903377964 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.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3e1bd07f35ff..3ef4ad2e3d8d 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.30", + "version": "1.14.31", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 5e450446ae68..92b62a1bfe1f 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.30", + "version": "1.14.31", "$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 4839bdc7eeda..b29e0d8878ad 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.30", + "version": "1.14.31", "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 428419434e26..5cbf063d392c 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.30", + "version": "1.14.31", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 79927045736f..16eaad15879f 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.30", + "version": "1.14.31", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 61ef5d05c380..b2d2c975c74f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 98a5ddd83f64..7d93297d02b7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.31", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 80c73f876b27..340a747d5260 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.30" +version = "1.14.31" 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.30/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/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.30/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/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.30/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/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.30/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index bce26ed57f77..d7536425c6c4 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.31", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cf2e574f51b1..ea91bef74bee 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.30", + "version": "1.14.31", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9082f1183103..75aba38d1bfd 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.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1fbf05d5e1c1..3da6b1b8a927 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.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index b19077be0409..01c0ab245c41 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 641f0667ca5d..c350b1e30690 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.31", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index c487d6ba424e..093d4d91a5c1 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.30", + "version": "1.14.31", "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 2d3783417309..f107f9fa5e3f 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.30", + "version": "1.14.31", "publisher": "sst-dev", "repository": { "type": "git", From a6b6395c8acd8ab096b344176f86c9aa2b8983a6 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 1 May 2026 11:33:44 +0200 Subject: [PATCH 0113/1114] 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 bee104a35d3e..e3e8074cd12a 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 0114/1114] 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 546cf3ced4de..cb7e8756486f 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 8576ec35628c..7c395022c14a 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 0115/1114] 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 8eda0595db44..99b7f1b15608 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 c4f9769224af..e755457e614d 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 009bb74d2ca0..0aa61c313a56 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 000000000000..963e2a2ed5d4 --- /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 de9618d302b0..af8f5d8d438a 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 c91f534b5a48..000000000000 --- 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 af16c04902c9..7f3aad7ed1a7 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 7f9d078bb7c4..7e4b4a6ff466 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 ac37823c34e1..95af410ff9d4 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 08f926d40deb..21a7810ce1ec 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 268e84f2ecc4..112b8a32988e 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 4e76a76a30db..03e8ee74b74f 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 30edbc782b46..f38c91ccecf9 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 667e610abc3c..f7571374839c 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 a8e490226b0d..762bb5d57ecc 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 594789b2075b..8545aef7f3b5 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 677c004be47e..9abf993d8077 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 28945f02133a..9dea20dd6604 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 11e9d8b1858a..5f2af06f1ee3 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 5d92635fbca5..b0b276841d7b 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 6a04833e35b4..e44a5ee3cd8f 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 71a3278cbb07..2e96dd980179 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 2da7c865d770..67261d7499a8 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 9bb1e50aac7f..b925ec60969d 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 22e66c7d16c8..cfd8277a3ba9 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 0116/1114] 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 5593efc9714b..e1d0c527aa86 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 9343326738cd..a01b7330e245 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 0117/1114] 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 c15197b6e3e1..2f75668e67e3 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 97b0abc64f31..e6dedb1a4b82 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 bee1e01ec096..5d61a903efed 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 ffb23f54c9eb..86446bfd853a 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 6281382d65ed..dc3febe0552d 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 0118/1114] 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 a017ead1e631..5f97074b20c0 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 89bae246a78c..afd24e7e1b38 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 0119/1114] 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 8618701ebf83..3f9df695aa35 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 0120/1114] 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 000000000000..4e13e8842430 --- /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 ec384709da10..1fc118d0d830 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 0121/1114] 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 1fc118d0d830..06bb103f068a 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 0122/1114] 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 92e40427681b..8dac8faf0129 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 3e94a08692c8..3d0767aaffa2 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 0123/1114] 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 ef33a48deac0..78436489f5f1 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 db6678754957..c20b08437219 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 0124/1114] 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 000000000000..22ceb28b3343 --- /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 000000000000..327835ea074d --- /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 623e886231ab..69cb74fd6da8 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 cd1bebec47a1..bcad2832e2e5 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 7b263980c554..53d54e2a81e0 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 c80f1caeb65d..1d7d84cbc065 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 e6dedfe2c4e8..783f84ec82e9 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 000000000000..f9fb6dca4ed4 --- /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 9dea20dd6604..6098ad9aaf7f 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 0125/1114] 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 000000000000..c94fa64af73f --- /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 17d6e0d063f9..c9668336ae92 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 a01b7330e245..2b8a62cc5f51 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 0126/1114] 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 69cb74fd6da8..aa4f48c56e20 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 1d7d84cbc065..4bb15cd3cdcb 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 0127/1114] 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 327835ea074d..7abb0bb7e33f 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 4bb15cd3cdcb..bf0093bd2ba6 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 0128/1114] 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 44841fe6fcd4..bfc3567bf57b 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 aa4f48c56e20..af7672872ca3 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 f38c91ccecf9..c8762bae6600 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 a52af7023ada..2d306f60ba84 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 0129/1114] 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 2604e703eaea..3190fda62fc9 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 adb7453a7264..8b62c5038bc3 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 bfc3567bf57b..9e9a6e3810f0 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 4a474881cb9f..4dd6a3ae7a69 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 5fcff772ec24..5110eccbf80c 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 ae52ac55034a..9f77de2d4dc9 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 22ceb28b3343..b281f492d4d8 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 7abb0bb7e33f..74df60ada714 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 af7672872ca3..549df4b75189 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 ae2761ac32a7..3c1dd350db00 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 bf0093bd2ba6..0e82da31b3ac 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 783f84ec82e9..3ac0298c6be9 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 19918b8b487d..622d6296f0a7 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 b9f86b18391c..14c8c87b0955 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 f7571374839c..06930d07ca09 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 fe3e45d66fdc..bf0008250592 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 0dd9a1af301f..23d416b53e03 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 6a7ccb96141f..2e9b6736f5eb 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 2d306f60ba84..3a5ce2323e74 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 f9fb6dca4ed4..2e3da29a7ad8 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 6098ad9aaf7f..15d3facd30d2 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 0130/1114] 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 74df60ada714..e96c421a7629 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 549df4b75189..5c7f1026331c 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 b925ec60969d..e46f8e04f070 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 cfd8277a3ba9..930fd8c92a05 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 0131/1114] 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 28f1068c36c1..1f3802e80c4a 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 5c7f1026331c..662b61bb0ffb 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 e9baf88538fd..16538bb8aec2 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 2e3da29a7ad8..b223bf91dbf0 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 88c67aa6dc0a..43b23dafadb1 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 d0aa299e70ee..edbf4d664817 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 0132/1114] 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 662b61bb0ffb..afc07ad26c56 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 0133/1114] 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 112b8a32988e..08e9e044bb7d 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 03e8ee74b74f..570f355e575d 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 e44a5ee3cd8f..48dcd885b221 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 0134/1114] 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 7e4b4a6ff466..485cb2e925fd 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 3c310129f151..16d8f93669c6 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 c8762bae6600..4a07aaf11c57 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 48dcd885b221..7fc1ec761d1f 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 0135/1114] 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 3190fda62fc9..aa6aef6a2317 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 8b62c5038bc3..41ca99a71549 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 9e9a6e3810f0..4dcab3e8dcf5 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 06969ff9d185..f3376ad8590e 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 afc07ad26c56..44ba397632b0 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 e78df61c2a0d..97fee3bfcf96 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 fa11e3e90d16..6ee9b4fadabd 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 14c8c87b0955..7db2bbddaefd 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 06bb103f068a..44ed0692a485 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 0daf8fe6a67d..101d3be72be5 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 2808344577b6..7e2138ea818f 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 cdacdd51793d..b24b79b33bc4 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 c3ae249e57d3..5b2e91e374ca 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 8545aef7f3b5..ddd10f2e06c7 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 54b2b42c8ff8..02945ac53ff9 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 091626be8df8..bf5e7a175f92 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 2c2663b0e0f6..e183f673f05d 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 4e83d0b906c0..07b42d994629 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 91605e15adfe..a861285a1153 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 5ce7eee939c7..d4f9192c761b 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 0064185f4625..850ad2dedd27 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 211a93a60230..e24cd05070fa 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 9abf993d8077..249087808d48 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 b223bf91dbf0..852c58ef41cc 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 a2a5cff60110..0d0e46fe4829 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 44a25a8e6bae..fac82fad34b1 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 edbf4d664817..924f42888b79 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 d44f41f1aac7..14cf1aefa6ce 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 2b8a62cc5f51..352fb2e2faf9 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 9469a66fd5a9..7d269b6bedb8 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 915d79784cfd..d7e48240a9c9 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 a4b0b6619901..0185af2df924 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 b7425007e152..81246eb0f00d 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 15d3facd30d2..ece01cf32329 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 4f9ccc512a63..22a56ba8a4ec 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 3d9245cd6ff9..61b1af61353f 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 0465b1cf6f37..656541be71c2 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 e348866528dd..d81d749f1d6b 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 5e8ff01a0e1b..3ff3893005e8 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 e4d22427cb45..2b6284a310ee 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 af373d933ba1..fd82e7863920 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 596ca4a5c4bc..771fb57019c0 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 5f2af06f1ee3..02d590f91893 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 f51a7145750a..d022c37974b5 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 3e844fad02f5..1fd3ce2b3931 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 7fc1ec761d1f..193c2971a11a 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 86c52096eb27..0177cde82f70 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 843986ba8cb1..43f188e74135 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 e2f92c20f6e1..7d479a73b094 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 e64d28bb44c2..e70847baf2f9 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 278fba94dcf8..b3230d4b8ad1 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 b85d570dc55b..c3216e1c5891 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 01dc74bb22ed..2c381ad047de 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 8a3189cab9c0..27623375c22c 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 c20b08437219..3fa61401e139 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 8e03177f8842..0cd3ec4d18a8 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 175c1526d05b..7473d2d56a47 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 147541f3d2cb..a8d62bb68c6f 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 cc9f87100c6c..4931d2a544f3 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 0136/1114] 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 1f93a43947bb..79034a01bb3c 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 000000000000..e47a1aeba5fe --- /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 000000000000..34a16aedd6fa --- /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 0137/1114] 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 fcd8e94431a8..a6dc1df844a1 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 9a0113030cb0..b15fbb254418 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 a26b8cfdfe88..7117ae7d1bfd 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 5c26d461e58c..306c03825e39 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 75aba38d1bfd..6efce0c57695 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 0138/1114] 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 b9ba578ac652..bea97a0cb338 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 0139/1114] 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 aa6aef6a2317..a0dd9fe2a133 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 41ca99a71549..0f0fd693d1de 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 4dcab3e8dcf5..46a31cf1c400 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 e96c421a7629..00075be64b81 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 44ba397632b0..22c2779ce1b6 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 97fee3bfcf96..f40a58453629 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 6ee9b4fadabd..530c02345aa1 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 7db2bbddaefd..14c8c87b0955 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 a861285a1153..23dd61d8803c 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 0140/1114] 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 a6dc1df844a1..2efb1208c3c7 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 2decf1fce4de..7196ddd4fd38 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 afb903377964..45eb7d0b7006 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 3ef4ad2e3d8d..e94be94983e9 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 92b62a1bfe1f..c8590d6aad0d 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 b29e0d8878ad..f72d7f100bac 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 5cbf063d392c..b1e8aa635a7f 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 16eaad15879f..5089278bfbf5 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 b2d2c975c74f..f16bf9368792 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 7d93297d02b7..b4c487f2f8a0 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 340a747d5260..ecc7e8f6bbc9 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 d7536425c6c4..52dfab2adfcd 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 ea91bef74bee..706986e4260d 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 6efce0c57695..2a96f1b8f319 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 3da6b1b8a927..8729c96a55a5 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 01c0ab245c41..32d830bba7aa 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 c350b1e30690..59039f05be5a 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 093d4d91a5c1..ab8031cf95d1 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 f107f9fa5e3f..185bc9339952 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 0141/1114] 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 22c2779ce1b6..5b2bcf6b32e7 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 14c8c87b0955..04cc432d08ad 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 02945ac53ff9..0a8972ca4a68 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 23dd61d8803c..1b193e382ab7 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 1b459481f30f..59fa54ceab0f 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 850ad2dedd27..5a0c4740219b 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 fac82fad34b1..3593c3ba0005 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 14cf1aefa6ce..83968a6f8c1a 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 d81d749f1d6b..32f6343ad82d 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 3ff3893005e8..5714f719a591 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 0177cde82f70..48e28aa5acc2 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 0142/1114] 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 5a0c4740219b..c615e55e5ed7 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 3593c3ba0005..806c47615b39 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 32f6343ad82d..6f2b4cee38f2 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 5714f719a591..b4cec9115fa6 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 0143/1114] 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 ba97405df99b..e10e85d2fe41 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 0144/1114] 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 ab2c12fb204c..8a9729436e70 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 118b81ba4e62..c7944e7cf6d3 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 7249f4dc9003..8ecb6a6eb943 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 45c4f08e3f6f..02b1b4987622 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 43a1189d6066..94e9c88faca4 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 5c86474a6198..6b66e7b5fcaa 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 cffa748ad21f..c5455654a6b9 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 973eb0d98832..6fd3d567693c 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 82a8852ea548..120803627ae0 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 b0ce10567efb..7b829c7f3715 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 8312a1a7c536..824a3dcadd10 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 e175870cbf0a..f2e4ddf9b219 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 78190b3c5dd4..889626d41793 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 f5aeee256f30..5f52f3d7f0c9 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 4b2db9d988c5..d98722846428 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 75ecca9926f5..25b74ecfe40a 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 c0cff134a556..aa992b673404 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 4df9d13fdd80..0d51bff2f842 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 0145/1114] 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 0b5d352755f8..cfbb959e7af0 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 000000000000..758ac6904a0c --- /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 adf52f5683a2..628aa95696aa 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 f3376ad8590e..97cd2f629e42 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 0146/1114] 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 758ac6904a0c..cc4dd2ed7ea3 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 0147/1114] 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 78436489f5f1..bf01fc7d2d5c 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 0148/1114] 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 cfbb959e7af0..183b1816d293 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 97cd2f629e42..bbf1f4f8de63 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 170fe516c97c..3654f66c79ed 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 7d9806d1391e..939110e044fb 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 c8689eabab9a..f9df530a9269 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 3ac0298c6be9..767bfc31db86 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 cc67355901cb..8ff7bc31035d 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 000000000000..feb5bb5893f2 --- /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 0149/1114] 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 3654f66c79ed..d3f9fa9eafea 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 feb5bb5893f2..7ccf126a9caa 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 0150/1114] 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 fb822ff17e8b..80c47d3ceda0 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 a9a853e504eb..ebe3bb530cde 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 0151/1114] 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 b31825fd997e..106d48466208 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 278522555f2d..c383e79ce867 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 d3f9fa9eafea..77e217eb7f8c 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 0152/1114] =?UTF-8?q?fix(instance):=20restore=20InstanceBo?= =?UTF-8?q?otstrap=20init=20parameter=20for=20non-Effec=E2=80=A6=20(#25449?= =?UTF-8?q?)?= 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 a0dd9fe2a133..da90ec4033cd 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 0f0fd693d1de..dd6f7e246d79 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 bbf1f4f8de63..66f3a9b37821 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 622d6296f0a7..db7b9b52f942 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 04cc432d08ad..dbca75c1959f 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 06930d07ca09..0036c9ab464c 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 0153/1114] 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 dbca75c1959f..01a45c2fb935 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 e46f8e04f070..b925ec60969d 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 930fd8c92a05..cfd8277a3ba9 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 0154/1114] 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 2efb1208c3c7..12677ea97632 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 7196ddd4fd38..5f4d79e44f4d 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 45eb7d0b7006..cb5b4bf9a4c8 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 e94be94983e9..bfb7f7db8f47 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 c8590d6aad0d..f6072bd37991 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 f72d7f100bac..d73a23e08103 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 b1e8aa635a7f..4ba8d1401b60 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 5089278bfbf5..7a26516a99e7 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 f16bf9368792..1327423e51a9 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 b4c487f2f8a0..16e142b9cf14 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 ecc7e8f6bbc9..d9e71219f5cf 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 52dfab2adfcd..1eb790ccedbe 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 706986e4260d..8c5aa3499823 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 2a96f1b8f319..d6bfdd844b09 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 8729c96a55a5..de69e685c546 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 32d830bba7aa..04b996aca7b2 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 59039f05be5a..cd210c4d61b0 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 ab8031cf95d1..c346fe5e7e16 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 185bc9339952..67617771f038 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 0155/1114] 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 f392bab4c8f3..8a5645e679e0 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 0156/1114] 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 8a5645e679e0..420972235746 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 0157/1114] 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 62ba20e2ca67..5ff282b543cc 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 0158/1114] 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 1ac0b071dd5a..1529e9b71df3 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 cc4dd2ed7ea3..29f750d16066 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 0159/1114] 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 d55aba091aa6..8d19376662a0 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 0160/1114] 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 6d00fdf9f4be..9a8843160b65 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 0161/1114] 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 9a8843160b65..966eb5f662c7 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 0162/1114] 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 52a3d7204e04..dbf27ccc6c74 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 0163/1114] 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 af16cba114fe..8bbc2427fc1b 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 106d48466208..a75dc31634ea 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 c94e9620386d..f73ca67175b7 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 8855338d1d4b..d43edd2dd5d7 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 720a05ff7e16..e7e4c7cea303 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 80c47d3ceda0..9f1420388e2e 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 ebe3bb530cde..a4eb31acc747 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 bf0008250592..bb2e4e58df13 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 000000000000..061253f8fb55 --- /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 000000000000..77d0f4b5ed7d --- /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 a131ed7e6339..5cba07805c1e 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 afd24e7e1b38..a7853be0b8bf 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 bc42b0324b8b..9f6a0617eda8 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 513cfa18eaab..43295e2d5d35 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 0164/1114] 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 cff9a7f9cc6a..831ca08b698e 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 0165/1114] 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 bb2e4e58df13..d3ca542684de 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 77d0f4b5ed7d..45c637863a6b 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 43295e2d5d35..e68d16ba811f 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 0166/1114] 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 a80b6a581932..8102fcfb88cc 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 8e4eaa4e4d66..1e2eb13bb77f 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 6312afcf18f9..b822a98bc1a3 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 9b7e82691568..73c7ada2b1bf 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 79179411b68f..e23410a69b81 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 6663398a45a4..1675f175df83 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 0167/1114] 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 73c7ada2b1bf..f0be704485c8 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 0168/1114] 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 da90ec4033cd..81a085d68959 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 dd6f7e246d79..e4fbeb2fbce5 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 46a31cf1c400..a63d77013f07 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 66f3a9b37821..901738646cf9 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 000000000000..b20cc54cd623 --- /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 9f77de2d4dc9..ea2aa2e84899 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 000000000000..a30bf5610711 --- /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 00075be64b81..41adcbc7cfd6 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 5b2bcf6b32e7..81977affc33f 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 000000000000..fbc300fad7f3 --- /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 f40a58453629..4a491d95b6ae 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 f055917b0c79..96a7e756de49 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 272b086065b8..75441b4ca4a3 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 58aa81098c75..753ba0313803 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 bcad2832e2e5..f9be57f4fd89 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 0e82da31b3ac..d4913696d299 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 767bfc31db86..ce1b21372999 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 530c02345aa1..f0da2f3d856a 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 db7b9b52f942..494459500d43 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 01a45c2fb935..3d8bb605bd58 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 0036c9ab464c..dbf693e8fc27 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 000000000000..89e8a66407ba --- /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 5b2e91e374ca..9c4cbd788c81 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 46a3f0626365..a3f2a1b5fb3a 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 0a8972ca4a68..f5e693388327 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 000000000000..4cd90c51bf5a --- /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 1b193e382ab7..38017e516cd7 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 59fa54ceab0f..2ba487f3f555 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 c615e55e5ed7..4d66784d8163 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 4bee9857963c..c77c0ca1c02a 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 e24cd05070fa..8c55950afffc 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 000000000000..bb8d43e0152d --- /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 852c58ef41cc..bc8809af9cc8 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 806c47615b39..60c66981d55b 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 83968a6f8c1a..694a37e99fe4 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 ece01cf32329..f311de2b4af1 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 6f2b4cee38f2..396d04feb81e 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 b4cec9115fa6..8118aa7842b7 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 f35e044d7baa..f3f7cbaef7b2 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 f80081759426..3bb38c87867a 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 0cd3ec4d18a8..f9ac07831ae4 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 9a01f95cd1c7..e836b23ebea2 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 0169/1114] 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 fbc300fad7f3..aa761a42b401 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 38017e516cd7..e6c8aebcbdd5 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 e836b23ebea2..e948a6dcb31c 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 b925ec60969d..e60ea76945b5 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 cfd8277a3ba9..0e9c11ca6e13 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 0170/1114] 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 29f750d16066..6785e0b612b8 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 0171/1114] 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 79ccf8b34910..3ebfb1627cf6 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 a63d77013f07..c6557360bb2c 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 14eefdd81c3a..8568ffbb9e08 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 dbee49dca6fd..ec7a965d2247 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 ad6a4644df7a..5854fe1f1afb 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 0172/1114] 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 e60ea76945b5..af29de17f21a 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 0e9c11ca6e13..680771e18b7c 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 ec7a965d2247..58b63fa34888 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 0173/1114] 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 000000000000..f0faa27602b0 --- /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 48399a5f4dbc..d2be0152114e 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 de4683b751fc..06cb99f97ffd 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 86967e11a0a7..d9abf18a526c 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 00564a17bf1c..41372b15a009 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 101d3be72be5..377c54109692 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 e6c8aebcbdd5..970365f53313 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 131ec5cc6bc2..2fbf5ca11b3b 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 694a37e99fe4..461fb88f26d5 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 000000000000..c8d0f92191fe --- /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 7d269b6bedb8..16e8975ea1e2 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 0185af2df924..5f36a327469a 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 f311de2b4af1..7a889aea04e2 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 22a56ba8a4ec..b5f0805e4c6f 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 1fd3ce2b3931..1b9e1c15035d 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 028436d2953d..94f401afd8b2 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 c807d12812a9..4b0da7c698d3 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 662073a8c388..3f2cba89419a 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 3fa61401e139..695d96ec2fe8 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 f9ac07831ae4..c33981ddff5f 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 4931d2a544f3..8bba52a4b2c8 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 0174/1114] 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 f0faa27602b0..1681f2e21202 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 2fbf5ca11b3b..e454fa7e42e9 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 461fb88f26d5..9e577ec3cd7b 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 0175/1114] 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 81a085d68959..fa39ecb177b5 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 a1a440eaa1ac..11a6c7f4301c 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 a75dc31634ea..e707526dfee9 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 d244549ffff6..e4d7bd9224e6 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 c383e79ce867..ca6452618231 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 2ef533324545..73193d142e1d 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 e4fbeb2fbce5..775f321bb5a5 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 901738646cf9..e8c8025ea3c9 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 000000000000..a7e2bfcb7b62 --- /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 a30bf5610711..c8803847a07f 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 41adcbc7cfd6..4fa1c3dfff66 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 81977affc33f..a54291cf0c7c 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 000000000000..b5b0e7c07964 --- /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 96a7e756de49..949734f81a7c 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 ce1b21372999..0b4bc252c3d1 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 494459500d43..23707faf798e 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 dbf693e8fc27..f5f667222f48 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 2e9b6736f5eb..43453b561a8a 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 bce5e94598cf..9a92fc507212 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 44ed0692a485..6996e54b4789 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 89e8a66407ba..72e538aa3a0d 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 7e2138ea818f..3e3d7a3e9055 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 b24b79b33bc4..876cb1ed7419 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 9c4cbd788c81..0a522b085049 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 ddd10f2e06c7..10a05e3b1ebe 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 699e713c2292..f345cd0850e4 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 bf5e7a175f92..cdd2e211c25b 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 3a5ce2323e74..5b59929ea581 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 e183f673f05d..7e47c513517e 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 970365f53313..d47620f62351 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 f3c7893d9700..7d9f5a715551 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 175717d0568c..5bc8f803d27b 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 2ba487f3f555..10547c9f0821 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 8b29f6d1e38d..3cf67742156d 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 3a6df02a15b7..20cb90a18e0a 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 d4f9192c761b..64b93bb8bcb9 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 4d66784d8163..1c3d6fc563f3 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 bb8d43e0152d..c01450549bf3 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 bc8809af9cc8..655e381b9a7b 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 0d0e46fe4829..6fb0e251d330 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 60c66981d55b..a89fda6ca5c1 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 43b23dafadb1..c35a03d78bc9 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 84478a34c457..8bb3b96347e2 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 924f42888b79..cdb9d2057245 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 9ef9741badf5..662042b64c85 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 3e4d6583557d..8c5d804b7304 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 7b8b4d67cac3..00e965d25ed6 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 9e577ec3cd7b..4e2c8ef9bb40 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 a5ab7b8f3633..9368089511c8 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 5f36a327469a..8684edf134e2 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 7a889aea04e2..410dbe742658 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 396d04feb81e..f442df577019 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 8118aa7842b7..c45a81838a6d 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 771fb57019c0..ce774ccfd063 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 02d590f91893..70fe2d81b350 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 d022c37974b5..b85658ea1ea6 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 43f188e74135..1ccc9bc8e624 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 7d479a73b094..20478dde844e 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 e70847baf2f9..e3c5e83136cf 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 b3230d4b8ad1..13edca14584e 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 f3f7cbaef7b2..df83adb8d40e 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 c648d62be82e..7b9608483292 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 17370bbe62a8..35b67f7a0711 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 99f20b44dcbb..bb69e459bc05 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 bdf95caed5ef..da2ffb79373c 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 c3216e1c5891..99ddfe72d456 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 c4cccc6eb548..fd24b557b3ca 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 2c381ad047de..23ae0e9090f5 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 ea1d340ce8ee..5914918178c3 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 e68d16ba811f..9b5c17c2224e 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 dbde4ed5b70b..6c7f6aba7702 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 0176/1114] 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 b5b0e7c07964..b7b5360c753b 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 655e381b9a7b..99b0f0666b5d 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 af29de17f21a..31bd40ab4ffc 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 680771e18b7c..208346325b10 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 0177/1114] 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 831ca08b698e..1a3f79396c00 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 8102fcfb88cc..15bd1c1a920d 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 1e2eb13bb77f..d9bb252ea988 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 b822a98bc1a3..b40b423181fa 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 f0be704485c8..ca95c1d55971 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 e23410a69b81..3b120da3cb74 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 1675f175df83..e37e63dc47bf 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 5ff282b543cc..bf73ce941e43 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 8d19376662a0..419e81379b32 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 dbf27ccc6c74..08c0df929c48 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 966eb5f662c7..8bf7b2345c90 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 0178/1114] 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 ca95c1d55971..8d1cbd2b1eae 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 0179/1114] 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 a3b8133b6466..ed52f90e6095 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 0180/1114] 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 5f3211aa1c62..a8a7234d9a38 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 6785e0b612b8..94ad0232cf14 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 0181/1114] 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 38c28032cdb9..e0755577b617 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 19ee38ff536b..f20381a01431 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 0182/1114] 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 ed52f90e6095..0daae55800c1 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 a61c15f7a7a4..e1962aed4ca9 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 000000000000..d5efe5f9e8b3 --- /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 000000000000..bb6d06237e41 --- /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 d79324fedf86..1f3bc493c132 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 000000000000..e0ffe7823c46 --- /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 000000000000..e594de2f0488 --- /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 cf9fcfbeec8c..3250c166ab4e 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 b5392a81b9b7..3cfd453624c1 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 9ee8e6fb039b..449694a53a3a 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 7117ae7d1bfd..ea742f699708 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 79034a01bb3c..a6ba797f33dd 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 000000000000..f82bb4d96227 --- /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 000000000000..7270a9c3b7f7 --- /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 856ee0ebb156..2b0d859192d4 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 4a491d95b6ae..da3614d2283e 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 474d92b31b33..52e9bc196447 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 81ea2394c061..1cf1584e3eea 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 25e810753e21..a5c328ac0e34 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 000000000000..05da5b720de2 --- /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 000000000000..3b0b2fa5b10e --- /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 000000000000..17ddcaeda3b9 --- /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 f9be57f4fd89..f80869b64d3f 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 000000000000..55cb53458172 --- /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 000000000000..3485d80fd636 --- /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 000000000000..558e34dd1842 --- /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 0b4bc252c3d1..e53eca3effa0 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 f0da2f3d856a..3f9f3f6607c1 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 aaee2be2feba..067d43da2e25 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 b475ec1c5997..1a32a656d135 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 000000000000..951e3e874f48 --- /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 a3832ebe655c..9819ad810fcd 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 9f1420388e2e..0590fc38274c 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 863fb21d65c7..421fa68694d2 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 e1d0c527aa86..fedfa8996e9f 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 332a5c76ebf4..1c88712d7d1e 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 000000000000..fde8d4326f4f --- /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 000000000000..44587b838a43 --- /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 3fe4266c04cb..000000000000 --- 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 66576a688e7e..000000000000 --- 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 aaf71c8dccdb..3af5932f0d24 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 000000000000..844f6fe2d17e --- /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 000000000000..8ec99bc200be --- /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 000000000000..86d8e52eb78d --- /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 2bac11f4fe3a..1777b875aa8c 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 000000000000..dee2bb11ed83 --- /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 9a92fc507212..2722757ab9e3 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 5b0fcad3c928..78253361b76c 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 479da7f518a6..b408f7ef11b8 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 352fb2e2faf9..b7ffa0ca5ed7 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 d7e48240a9c9..940efed9c359 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 70fe2d81b350..d96347bed8c0 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 df83adb8d40e..0d02d9918a29 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 53305694018c..a602c0c8d7aa 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 defce40c14f3..000000000000 --- 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 0afbb1831757..234c5246eeee 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 000000000000..128177167cbb --- /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 e920cc0fdb15..c490a0be7079 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 67261d7499a8..74c5844626ee 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 31bd40ab4ffc..caa3d4c76770 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 000000000000..20d84c8f4748 --- /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 0183/1114] 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 bb6d06237e41..a237b4156eee 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 1f3bc493c132..740ba0e2546b 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 e594de2f0488..1eb0cf0b07c3 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 52e9bc196447..aeb1da539339 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 1a32a656d135..e2a47f180088 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 fedfa8996e9f..09d2c8c3c3ab 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 844f6fe2d17e..ad1aa32e708a 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 d96347bed8c0..c9a0b53bb428 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 208346325b10..b1c4ec1d76df 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 0184/1114] 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 b68c3a335607..146d7b4d0758 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 ea2aa2e84899..fb3e1bb32da7 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 f30d2e90c708..a2c1a097b10c 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 e69b8e6df21b..9906b3164551 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 0185/1114] 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 94ad0232cf14..ceb52d07ad82 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 0186/1114] 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 9095984fe34a..87671f5a005a 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 11a6c7f4301c..401126949569 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 194e66b1f202..2603663fb42c 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 0187/1114] 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 fe6d62d7b629..05af009b8846 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 f73ca67175b7..72096dba3182 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 ceb52d07ad82..b0f6de16b711 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 0188/1114] 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 72096dba3182..75f68e8ea0ab 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 0189/1114] 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 8bbc2427fc1b..d66c1b258325 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 87671f5a005a..251c60884301 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 0190/1114] 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 3fedd41d2cea..d93670709e11 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 0191/1114] 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 9c0a3a3a35f2..6e13266f45a0 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 0192/1114] 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 35812f953ddf..2f2edb4ff5ac 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 0193/1114] 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 e707526dfee9..f946e91ed4ff 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 0194/1114] 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 f946e91ed4ff..a4a209ea39a4 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 0195/1114] 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 e4d7bd9224e6..c220cbbdd872 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 0196/1114] 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 c220cbbdd872..a2a956c3b629 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 0197/1114] 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 12677ea97632..25068f3d9a56 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 b15fbb254418..de3dd31f4034 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 0198/1114] =?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 8bf7b2345c90..0124a26932d6 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 0199/1114] 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 8cc969f483f5..4a67ba036e06 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 000000000000..6922d8c43f78 --- /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 0200/1114] 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 401126949569..e2565c6272de 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 a2a956c3b629..d1e8b33be721 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 ca6452618231..93541114b46f 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 0201/1114] 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 e2565c6272de..a5bcd7873ba7 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 d1e8b33be721..d9927e287fd4 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 93541114b46f..71e03c7e79bf 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 0202/1114] 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 1777b875aa8c..1f4cbcf1e0c3 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 0203/1114] 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 bea97a0cb338..84c3b13043f5 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 0204/1114] 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 71e03c7e79bf..3dce55d32463 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 0205/1114] =?UTF-8?q?refactor(cli/mcp+agent):=20Stage=204?= =?UTF-8?q?=20=E2=80=94=20drop=20AppRuntime.runPromise=20bridges=20(#25530?= =?UTF-8?q?)?= 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 a5bcd7873ba7..2026d8232487 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 d9927e287fd4..2ae7cece6a27 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 0206/1114] =?UTF-8?q?refactor(cli/providers):=20Stage=204?= =?UTF-8?q?=20=E2=80=94=20drop=20inline=20AppRuntime.runPromise=20calls=20?= =?UTF-8?q?(#25532)?= 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 3dce55d32463..081bcece000b 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 0207/1114] 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 b0f6de16b711..ada5f8677d77 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 000000000000..de6fed8daa68 --- /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 0208/1114] 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 2603663fb42c..6e2643f68896 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 0209/1114] 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 3b8519d3bbec..e5f8f000e0e1 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 a77b92737bc9..f6f2130f0490 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 0210/1114] 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 f6f2130f0490..a4c8454a9def 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 0211/1114] 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 a4c8454a9def..03df339cb89a 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 56886808a49a..e03b1fdd9c71 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 000000000000..a71c6af3180c --- /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 0212/1114] 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 e03b1fdd9c71..35db44641ee6 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 0213/1114] 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 fe1ec8409b43..96234eb25d9a 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 92e6c47570a0..42f1d3c51a34 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 2310bfcc86b7..8930ba693cc8 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 0214/1114] 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 3f9df695aa35..000000000000 --- 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 4c2aa960b2a8..000000000000 --- 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 51816dfb7590..000000000000 --- 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 79687639df29..000000000000 --- 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 0215/1114] 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 31cf08233b99..000000000000 --- 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 2f0f023cfd09..000000000000 --- 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 0216/1114] 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 a71c6af3180c..000000000000 --- 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 0217/1114] 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 16a8624474f0..fff1d70b2a41 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 24112cf4422d..28ac143eecf8 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 6fb0e251d330..53ff547ac14c 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 0218/1114] 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 1681f2e21202..5bfcae14ebe9 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 aa784c90df48..1b8c42c8993c 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 051d64c24db0..069f308512e1 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 c7c447ce85b3..cc85321685b0 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 4a07aaf11c57..caa520f7cad2 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 e53eca3effa0..650efe2b0d64 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 d2be0152114e..a7a0c9cbdce8 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 403d85d66ca1..ce06b2b35ee1 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 000000000000..659764970b73 --- /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 000000000000..40aaf04a96fe --- /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 000000000000..db67749e0821 --- /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 000000000000..366c455dd6bb --- /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 f5f667222f48..6d4cae807c95 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 7c9739f51ded..09b234bde97d 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 22c44a6dffe2..a921ae2774c5 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 0219/1114] 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 caa520f7cad2..a91a9992dfea 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 6d4cae807c95..097287530596 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 0220/1114] 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 5bfcae14ebe9..9755cf401779 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 768002957dbd..cb15b484e3c2 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 6ebc8dc487f6..13ec7061639a 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 b7ffa0ca5ed7..615899f2b423 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 1b9e1c15035d..8d2670c4922d 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 c490a0be7079..946ad1402b09 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 0221/1114] 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 b1c4ec1d76df..df00c1726661 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 0222/1114] 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 000000000000..fd65b0ae67e5 --- /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 000000000000..de7b5987ec34 --- /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 0223/1114] 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 251c60884301..1bf52a0c8f03 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 0224/1114] 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 fbda2dc50e02..32936e993568 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 e536f3c4ea77..e7a02d6151e3 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 2d71d8446de6..8b49e7f101b5 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 0225/1114] 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 32936e993568..dabc6dfe18c9 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 0226/1114] 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 e022a568ac07..05b8738971bb 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 09b234bde97d..1de8a489cdae 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 0227/1114] 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 05b8738971bb..4edd06479b7b 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 0228/1114] 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 9981edad7f3f..4614226a8a44 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 fa088cd65d4a..da734dc81def 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 855c6a3878cf..cb0f26b94dfc 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 0229/1114] 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 4cd30a704a4a..b28d963d005a 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 735b078be10f..c571de322a3e 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 0230/1114] 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 1bf52a0c8f03..e24262307ce5 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 75f68e8ea0ab..2ec0b179b823 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 cb6b95a56cb6..5de937fdcc19 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 775f321bb5a5..90ff2b4d4f52 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 95af410ff9d4..7a7f260df897 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 000000000000..9630ddbe20ed --- /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 4edd06479b7b..bd9552edcd6c 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 650efe2b0d64..2944ced69565 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 000000000000..1278e8c72e8c --- /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 c3bab23ac720..d780b18f24f2 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 1de8a489cdae..8b7a6a1ac35b 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 0231/1114] 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 44346be8f942..8a1cc3a08fc3 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 b77f4e356fa0..1d9405333da6 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 fbedcccc1b4a..e9824a09d62e 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 c6557360bb2c..3a933f81e967 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 90f49ee799eb..82fca570f4cf 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 a3f2a1b5fb3a..5053a7e1f794 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 0232/1114] 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 e9824a09d62e..890f73622853 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 0233/1114] =?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 a4a209ea39a4..ea5b35ef7868 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 2ec0b179b823..c20833d4beb3 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 0234/1114] =?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 081bcece000b..c8d897bea855 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 0235/1114] 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 c8d897bea855..44fa42015309 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 0236/1114] 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 c20833d4beb3..a05b273e4489 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 0237/1114] 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 ff5ff9dada89..998936bc68bf 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 000000000000..c85863abd7d9 --- /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 000000000000..146df16b776b --- /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 8c5aa3499823..adb4a7db1b12 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 fd65b0ae67e5..000000000000 --- 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 000000000000..5d29fae33f2c --- /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 000000000000..5f3804c1072c --- /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 cc7c385b3eb8..2e2c4ee1cb99 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 bd9552edcd6c..2a8f1cf4d41b 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 e354dccbfa64..0a1745f9377b 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 000000000000..4463c9c5900e --- /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 13ec7061639a..0383dc66f6e8 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 31ac4814685e..22f2648c92b5 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 d780b18f24f2..850098926a47 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 000000000000..3ee57dc10874 --- /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 de7b5987ec34..000000000000 --- 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 829f899605de..d3ca4ae6835b 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 0238/1114] 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 146df16b776b..d364762d7e70 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 0a1745f9377b..230f5b105bfa 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 4463c9c5900e..7cbac4ed5f24 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 0383dc66f6e8..6c7a6743dbe7 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 0239/1114] 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 f82bb4d96227..9801f0a2f84a 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 e2a47f180088..cf1a7e0ae921 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 951e3e874f48..88f73acf1a76 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 3af5932f0d24..47938dcbed08 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 ad1aa32e708a..d5d5aac7b7f1 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 8ec99bc200be..94f6b1cac276 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 caa3d4c76770..79ef42d9e171 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 0240/1114] 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 df00c1726661..21c547c85345 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 0241/1114] 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 db67749e0821..c1558a1a4ea3 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 8b7a6a1ac35b..332ad16c64da 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 0242/1114] 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 332ad16c64da..f364491ace93 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 0243/1114] 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 44fa42015309..749139e2dcd6 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 7f9cd8cfe649..2713f1a5b87a 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 0244/1114] 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 998936bc68bf..d4212e32e930 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 000000000000..1fa35247c8b5 --- /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 1204fba55710..a981d99fa1d9 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 ade572c2fd50..5115f0348ad4 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 000000000000..4666b7d6d03c --- /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 ae849b71eed6..603784e4d42f 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 c85863abd7d9..5fa1506b1e65 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 d364762d7e70..c1c7abad4ac0 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 0245/1114] 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 72e538aa3a0d..3ac923c4351e 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 0246/1114] 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 a97f3e9e8d40..d520750035db 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 0247/1114] 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 d4212e32e930..7bcc02d62d88 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 c1c7abad4ac0..06facdc7d245 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 e8c8025ea3c9..76ed26d302f5 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 000000000000..d40301cad2bc --- /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 62a181af3a54..92296a3b7dbf 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 7c5861d919cc..506e79818704 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 d2cc9b538dc3..898acaf089e5 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 d54bda4a84a6..3304ab9fbfd3 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 2e2c4ee1cb99..e5ff300a2a04 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 2a8f1cf4d41b..6c6d0cd1f125 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 2944ced69565..a3754c2e1907 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 3f9f3f6607c1..89b5641e5898 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 bff0b71915a4..fb8d5e356db9 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 6c7a6743dbe7..3971214f3dae 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 000000000000..0efd06e6a7ab --- /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 000000000000..1b7d6005bf30 --- /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 3ee57dc10874..af4c0a01ce01 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 74c5844626ee..e94132c2b2e3 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 79ef42d9e171..86c5a762b114 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 0248/1114] 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 d40301cad2bc..b5e5747c513c 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 1b7d6005bf30..4886f250f942 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 21c547c85345..6ff18b515579 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 0249/1114] 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 7bcc02d62d88..d8ed63b8d23f 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 6e07e0312412..623303fbf468 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 31d2d6e04ca8..0dcebd567d1e 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 7e9e2d32aaba..a08372649f11 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 2c2d9817f0c3..d7868d917019 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 0250/1114] 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 623303fbf468..5bca1b4b7edd 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 0dcebd567d1e..f6751c3f0ec7 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 0251/1114] 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 d8ed63b8d23f..6dae9de9550b 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 898acaf089e5..160d258796b7 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 6c6d0cd1f125..6f5648f30a99 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 000000000000..fece09592fa9 --- /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 c1558a1a4ea3..40d8aa7afb02 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 0252/1114] 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 af4c0a01ce01..7258b32a92ab 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 f364491ace93..85162f6a92c2 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 0253/1114] 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 7258b32a92ab..98ae30e8a722 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 0254/1114] 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 939110e044fb..4013dcee36e7 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 0255/1114] 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 2f75668e67e3..8bab495b7296 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 0256/1114] 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 07a19b8ad2f2..c4812fe5d5ee 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 4087db8cde4a..f6e54fc6ad15 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 8817d2319227..9b04d6be82f9 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 87f78c9d22f8..92981469309c 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 b44ce9ee9980..11489609bc64 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 6a902ab02f01..90bdb1fbc304 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 96da8c4df1d8..f2d195d7218b 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 8017d0882e86..c969c6d4a0de 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 6ca3afccc348..87278bef23f9 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 1fe8812e67f9..bf276918a9ba 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 deadd07d6a49..0e722d5fde2e 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 7c395022c14a..8410c549f292 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 50f841cf3624..174bc1679b61 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 f5868ceaa08e..39aae9e09630 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 818f39213c21..07008de218e2 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 527c20e15ef2..8c6ef23fee31 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 80dfe1e93d07..9c0a5d8a3bd5 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 c87417095992..d4e55ed712e2 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 0257/1114] 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 43453b561a8a..f4e4d2721ceb 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 a89fda6ca5c1..b191a3c9523f 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 000000000000..768a261a0058 --- /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 0258/1114] 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 3971214f3dae..ca86599955b2 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 98ae30e8a722..b49fbe98b5f4 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 0259/1114] 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 3ac923c4351e..dff972d100ef 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 000000000000..9dd5f3910e05 --- /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 000000000000..892f63646626 --- /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 0260/1114] 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 8bab495b7296..7f36246ee5b1 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 0261/1114] 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 000000000000..6f3c33a647a5 --- /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 a3754c2e1907..ef966036a94f 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 0262/1114] 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 25068f3d9a56..3cf2d9ce99a9 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 5f4d79e44f4d..ac9bfd590429 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 cb5b4bf9a4c8..85e855c55f36 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 bfb7f7db8f47..d5157a372cbb 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 f6072bd37991..0bb12654191f 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 d73a23e08103..b685bb1aab50 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 4ba8d1401b60..5f3371b98898 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 7a26516a99e7..8a6fcf5786fb 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 1327423e51a9..2ec5cd059415 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 16e142b9cf14..fe9de8584818 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 d9e71219f5cf..17b51a625733 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 1eb790ccedbe..f9044078b7b4 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 adb4a7db1b12..08d3171510f1 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 d6bfdd844b09..a8c17f19f428 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 de69e685c546..b3e12fc25385 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 04b996aca7b2..8a2ba85b025e 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 cd210c4d61b0..0c8621623852 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 c346fe5e7e16..8187602b0998 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 67617771f038..43f07930ef6b 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 0263/1114] 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 28ac143eecf8..8b3bedbf5bf1 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 53ff547ac14c..06da6ccba113 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 463a729778d0..edaa15b84ba1 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 a5fbdbc5c081..2da8c61a76cf 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 0264/1114] 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 06da6ccba113..82eacfb6df8f 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 0265/1114] 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 3cf2d9ce99a9..07415dd79fe6 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 ac9bfd590429..cde4986d1861 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 85e855c55f36..fb2e71d22d35 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 d5157a372cbb..7301b23e5c38 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 0bb12654191f..06fb0affd03c 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 b685bb1aab50..674fc55fd5e4 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 5f3371b98898..e90ab7628ab6 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 8a6fcf5786fb..ba981e637aa2 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 2ec5cd059415..e60320300a12 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 fe9de8584818..dce25e204df2 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 17b51a625733..775f826d4c2b 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 f9044078b7b4..1039677b52b5 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 08d3171510f1..bafa532de7ff 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 a8c17f19f428..661201d2d94b 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 b3e12fc25385..bef0fee14161 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 8a2ba85b025e..448df66401a1 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 0c8621623852..dcf52499d640 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 8187602b0998..a243a470789a 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 43f07930ef6b..22d8adc54b95 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 0266/1114] 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 dcf52499d640..1bc70c15ab5e 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 edaa15b84ba1..172fe8d6c2bc 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 2da8c61a76cf..bd6bed88d8f6 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 28c2fa2de0d2..0183fabca411 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 0267/1114] 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 b486acb99d4a..4ae8e2465f58 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 0268/1114] 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 000000000000..3bdf2b85e9cb --- /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 000000000000..4f6ebe00c0a2 --- /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 9755cf401779..771e1e417ebb 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 04c6b9945c8f..09d952ef8192 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 0aa61c313a56..e2af0d63e163 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 a6ba797f33dd..74332c77be77 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 000000000000..efdbf7158773 --- /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 7adc4c1db149..0f9214092eba 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 4d68c4430891..ef7d4bd3bbd6 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 485cb2e925fd..fe651fe3e364 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 21a7810ce1ec..788aef3176b9 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 58d30b4c787b..442e6565547b 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 08e9e044bb7d..f197ab976541 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 f4a2f315cd90..152d22f98e6b 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 570f355e575d..b415943a6242 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 89b5641e5898..71662dea903d 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 b7bf413d4ed1..9894d8c8eec7 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 b51b5a5dfebc..547a80f0f345 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 2654767e9a8b..62b30ccf9ab1 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 10a05e3b1ebe..84f5670064f9 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 193c2971a11a..21bf4120c951 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 234c5246eeee..0986b3904409 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 e94132c2b2e3..ab191b056653 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 86c5a762b114..a40b567f8c38 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 6ff18b515579..1a2f1e947537 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 0269/1114] 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 ab191b056653..ffc0970c0eba 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 a40b567f8c38..c0255754d965 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 1a2f1e947537..db8889f1a4cb 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 0270/1114] 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 c9f16606aeda..af7fd42583e8 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 0271/1114] 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 e2af0d63e163..ad406375759c 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 0272/1114] 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 4ae8e2465f58..7a4b5ef65e0f 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 0273/1114] 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 000000000000..3a4b9cf2415b --- /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 0274/1114] 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 1acc3f47f181..6560d308c17c 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 9801f0a2f84a..d9d23999d21a 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 7270a9c3b7f7..2e5cea9804e3 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 46c210fa5d2b..6d9a6447a068 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 cf1a7e0ae921..f22da92927d2 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 88f73acf1a76..93298170cc0c 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 0590fc38274c..e1fa81abf1bf 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 000000000000..1cc443974d89 --- /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 000000000000..db66199a59bb --- /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 47938dcbed08..7c768bd551a5 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 d5d5aac7b7f1..80ecb1011ebb 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 94f6b1cac276..024e28c45041 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 1f4cbcf1e0c3..bb86f039b230 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 c9a0b53bb428..34cecd80d0ae 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 128177167cbb..44ac031edab5 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 20d84c8f4748..000000000000 --- 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 3a4b9cf2415b..77c650e55fc6 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 0275/1114] 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 e1fa81abf1bf..8286ecf8e60f 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 e58ea9b122cf..22e4e5671c89 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 a602c0c8d7aa..c5170f346492 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 a8d62bb68c6f..f75fcf84b8a9 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 0276/1114] 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 c0255754d965..7734ca53ebc2 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 db8889f1a4cb..fea9dd5a958b 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 0277/1114] 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 409fcbeff60e..eafea591ae0c 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 8dbca8eea176..2413613730a0 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 337e1ca0bcc4..387e793b0eae 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 6e13266f45a0..967ff54eb735 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 0278/1114] 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 eafea591ae0c..2917b7adb838 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 967ff54eb735..cb4b5a448177 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 0279/1114] 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 4614226a8a44..5f7ee96b90d1 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 07415dd79fe6..5067655ae9c8 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 de3dd31f4034..335a8b3b1dfb 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 ac9d8db96943..000000000000 --- 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 7805ea835f5b..000000000000 --- 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 ebaf48822313..000000000000 --- 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 ba981e637aa2..000000000000 --- 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 6ef3335eb79a..000000000000 --- 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 37c31d7eedb7..000000000000 --- 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 0dfd5a35cbf8..000000000000 --- 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 19b96b0a161f..000000000000 --- 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 64441936d7a0..000000000000 --- 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 9637fe03ddc1..000000000000 --- 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 a547bf36d8d1..ac9d8db96943 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 3839db1a9041..7805ea835f5b 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 358b7d24d511..ebaf48822313 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 ce2775a7047d..000000000000 --- a/packages/desktop/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - OpenCode - - - - - - - - - - - - - -
-