summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-29 09:34:50 -0400
committerGitHub <[email protected]>2026-04-29 09:34:50 -0400
commit6015084fa2502bf4dc941ae39c538f089a0d89b4 (patch)
treee2888e8f3aa20e07a811e7dc6ee3b848401bfee8
parent65ba1f6c138636e0e731905951845da2b76c9add (diff)
downloadopencode-6015084fa2502bf4dc941ae39c538f089a0d89b4.tar.gz
opencode-6015084fa2502bf4dc941ae39c538f089a0d89b4.zip
Prepare Effect HttpApi backend parity (#24853)
-rwxr-xr-xpackages/opencode/scripts/diff-sdk-types.sh52
-rw-r--r--packages/opencode/specs/effect/http-api.md8
-rw-r--r--packages/opencode/src/agent/agent.ts6
-rw-r--r--packages/opencode/src/auth/index.ts3
-rw-r--r--packages/opencode/src/bus/bus-event.ts12
-rw-r--r--packages/opencode/src/cli/cmd/tui/event.ts3
-rw-r--r--packages/opencode/src/config/agent.ts4
-rw-r--r--packages/opencode/src/config/console-state.ts3
-rw-r--r--packages/opencode/src/config/mcp.ts6
-rw-r--r--packages/opencode/src/config/provider.ts22
-rw-r--r--packages/opencode/src/file/index.ts14
-rw-r--r--packages/opencode/src/file/ripgrep.ts28
-rw-r--r--packages/opencode/src/lsp/lsp.ts10
-rw-r--r--packages/opencode/src/project/project.ts8
-rw-r--r--packages/opencode/src/project/vcs.ts6
-rw-r--r--packages/opencode/src/provider/auth.ts4
-rw-r--r--packages/opencode/src/provider/models.ts22
-rw-r--r--packages/opencode/src/provider/provider.ts18
-rw-r--r--packages/opencode/src/pty/index.ts10
-rw-r--r--packages/opencode/src/server/backend.ts32
-rw-r--r--packages/opencode/src/server/middleware.ts23
-rw-r--r--packages/opencode/src/server/proxy.ts8
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/api.ts54
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/event.ts11
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/global.ts259
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/config.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/config.ts)44
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/control.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/control.ts)46
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/experimental.ts)203
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/file.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/file.ts)78
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/global.ts106
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/instance.ts)97
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/mcp.ts)115
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts18
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/permission.ts)44
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/project.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/project.ts)66
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts74
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts121
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/question.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/question.ts)52
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/session.ts428
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts90
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts164
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts103
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts34
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts34
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts155
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts54
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts156
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts79
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts68
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts29
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts46
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts89
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts118
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts33
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts (renamed from packages/opencode/src/server/routes/instance/httpapi/session.ts)491
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts54
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts134
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts66
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/instance-context.ts191
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts17
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/provider.ts157
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/pty.ts242
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/public.ts448
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts200
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/sync.ts137
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/tui.ts291
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/workspace.ts166
-rw-r--r--packages/opencode/src/server/server.ts41
-rw-r--r--packages/opencode/src/server/workspace.ts23
-rw-r--r--packages/opencode/src/session/message-v2.ts104
-rw-r--r--packages/opencode/src/session/message.ts28
-rw-r--r--packages/opencode/src/session/session.ts28
-rw-r--r--packages/opencode/src/session/status.ts6
-rw-r--r--packages/opencode/src/snapshot/index.ts6
-rw-r--r--packages/opencode/src/storage/storage.ts5
-rw-r--r--packages/opencode/src/sync/index.ts16
-rw-r--r--packages/opencode/src/tool/bash.ts3
-rw-r--r--packages/opencode/src/tool/codesearch.ts2
-rw-r--r--packages/opencode/src/tool/lsp.ts12
-rw-r--r--packages/opencode/src/tool/read.ts5
-rw-r--r--packages/opencode/src/util/named-schema-error.ts7
-rw-r--r--packages/opencode/src/util/schema.ts2
-rw-r--r--packages/opencode/src/v2/session-entry.ts15
-rw-r--r--packages/opencode/src/v2/session-event.ts22
-rw-r--r--packages/opencode/test/server/httpapi-bridge.test.ts50
-rw-r--r--packages/opencode/test/server/httpapi-experimental.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-file.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-instance.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-json-parity.test.ts6
-rw-r--r--packages/opencode/test/server/httpapi-mcp.test.ts4
-rw-r--r--packages/opencode/test/server/httpapi-provider.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-pty.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-sdk.test.ts566
-rw-r--r--packages/opencode/test/server/httpapi-session.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-sync.test.ts4
-rw-r--r--packages/opencode/test/server/httpapi-tui.test.ts2
-rw-r--r--packages/opencode/test/server/httpapi-workspace.test.ts116
-rw-r--r--packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap15
98 files changed, 4294 insertions, 2770 deletions
diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh
new file mode 100755
index 000000000..b27a31e8c
--- /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 791aa0e28..6d6602e94 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 5e839ead5..81dbded08 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 539c40c1a..3d6a0d91d 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>("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 f27d26335..cf9fcfbee 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 1c764c12f..fbe5ce7f9 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 e673edbad..e72f65872 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 08668afe4..0d4f20df9 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>("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 0887fa984..fc31ba356 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 cd7469435..7821bca5a 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 122add21f..4a474881c 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<Schema.Schema.Type<typeof Node>>
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 3a5411c31..27fd5f232 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 45a818997..5fcff772e 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 648bfc8fe..4229112a8 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 e12a031d6..24112cf44 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<typeof Info>
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 4df83f020..6cbfcf1be 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<Authorization>("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<typeof AuthorizeInput>
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<typeof CallbackInput>
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index ed2d11eb7..170fe516c 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 c05d05319..48df5a4c9 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 beccade09..2518800ce 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<Schema.Schema.Type<typeof UpdateInpu
export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
- Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })),
+ Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
}
diff --git a/packages/opencode/src/server/backend.ts b/packages/opencode/src/server/backend.ts
new file mode 100644
index 000000000..f456dc0be
--- /dev/null
+++ b/packages/opencode/src/server/backend.ts
@@ -0,0 +1,32 @@
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
+
+export type Backend = "effect-httpapi" | "hono"
+
+export type Selection = {
+ backend: Backend
+ reason: "env" | "stable" | "explicit"
+}
+
+export type Attributes = ReturnType<typeof attributes>
+
+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<string, string> {
+ 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 ffcfd4ce0..c653156d3 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 441d7a5c2..f93150020 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 000000000..81ea2394c
--- /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 1d548e0ba..9f4ddde4c 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 ef7fb331f..000000000
--- 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<typeof setInterval> | 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<Uint8Array>({
- 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
index eef825967..4ff406e2a 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
index 718629db7..33e6a8e4a 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
index cc39c7604..2a562b46b 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
index df525680a..3a4f3df7f 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 000000000..272b08606
--- /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
index 8c471c12a..cc450f448 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
index f5552f6f2..149f8814a 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<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
+})
+export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("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 000000000..f4841c538
--- /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<S extends Schema.Top>(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
index 357c83299..e06c98d9e 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
index 276798b0b..92019866e 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 000000000..56dace0e5
--- /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 000000000..e3914579c
--- /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
index 2169e17c5..de249823b 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 000000000..5a388f187
--- /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 000000000..1d9b08d9c
--- /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 000000000..a5d31bfa6
--- /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 000000000..0305c6536
--- /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 000000000..2fc225d17
--- /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 000000000..abddd8c40
--- /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 000000000..42eab762e
--- /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 000000000..72133e8de
--- /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 000000000..597239551
--- /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<GlobalBusEvent>((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 000000000..b6f386065
--- /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 000000000..b4d27d91d
--- /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 000000000..a5d6dab89
--- /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 000000000..20a5ddfb0
--- /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 000000000..f343829d6
--- /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<string, (typeof all)[string]> = {}
+ 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 000000000..f2f17d471
--- /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 000000000..53ca568cf
--- /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
index 6ea19f19e..d6264b605 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 = <A, E, R>(self: Effect.Effect<A, E, R>) =>
+ 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 000000000..3ae091484
--- /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 000000000..cb12ccb7a
--- /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 000000000..9413c865d
--- /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 000000000..1ad42c526
--- /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<HttpServerResponse.HttpServerResponse, unhandled, never>
+
+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<void, unknown>, 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<string | Uint8Array> = []
+ 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<Target, { type: "remote" }>,
+ 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<HttpServerRequest.HttpServerRequest, never>((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 1916e4269..e1c03e7bd 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<object, InstanceContext>()
-const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[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<typeof Instance.reload>[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 7dbc491e1..000000000
--- 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<string, (typeof all)[string]> = {}
- 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 d4e77c9d0..000000000
--- 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 a4e86e9a5..d9871c69b 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<string, unknown>
+ responses?: Record<string, OpenApiResponse>
requestBody?: {
required?: boolean
content?: Record<string, { schema?: OpenApiSchema }>
}
+ security?: unknown
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
@@ -38,6 +23,7 @@ type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch
type OpenApiSpec = {
components?: {
schemas?: Record<string, OpenApiSchema>
+ securitySchemes?: Record<string, unknown>
}
paths?: Record<string, OpenApiPathItem>
}
@@ -47,16 +33,25 @@ type OpenApiSchema = {
additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[]
- enum?: string[]
+ description?: string
+ enum?: Array<string | boolean>
items?: OpenApiSchema
maximum?: number
minimum?: number
oneOf?: OpenApiSchema[]
prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema>
+ required?: string[]
type?: string
}
+type OpenApiResponse = {
+ description?: string
+ content?: Record<string, { schema?: OpenApiSchema }>
+}
+
+// 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<string, OpenApiSchema>
+const LegacyComponentDescriptions = {
+ LogLevel: "Log level",
+ ServerConfig: "Server configuration for opencode serve and web commands",
+ LayoutConfig: "@deprecated Always uses stretch layout.",
+} satisfies Record<string, string>
+
function matchLegacyOpenApi(input: Record<string, unknown>) {
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<string, unknown>) {
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<string, OpenApiSchema>) {
+ 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, OpenApiSchema>): string {
+ return JSON.stringify(canonicalizeSchema(input, schemas))
+}
+
+function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): 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<string, OpenApiSchema>) {
+ 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<string>()
+ 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 e96c21b55..2f4bde918 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<unknown>
-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 67fcede2f..000000000
--- 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 2bcc740dd..000000000
--- 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 1c5b4f87d..000000000
--- 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 92d844fbf..40e709edd 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<Response>
}
-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<T extends { app: ServerApp; runtime: unknown }>(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 5117fb8fa..29b1ab986 100644
--- a/packages/opencode/src/server/workspace.ts
+++ b/packages/opencode/src/server/workspace.ts
@@ -21,7 +21,7 @@ const RULES: Array<Rule> = [
{ 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 6b64b0231..b1a6ff403 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<Schema.Schema.Type<typeof Reasonin
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
- start: Schema.Int,
- end: Schema.Int,
+ start: NonNegativeInt,
+ end: NonNegativeInt,
}).annotate({ identifier: "FilePartSourceText" }),
}
@@ -162,7 +162,7 @@ export const SymbolSource = Schema.Struct({
path: Schema.String,
range: LSP.Range,
name: Schema.String,
- kind: Schema.Int,
+ kind: NonNegativeInt,
})
.annotate({ identifier: "SymbolSource" })
.pipe(withStatics((s) => ({ 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<Schema.Schema.Type<typeof SubtaskPar
export const RetryPart = Schema.Struct({
...partBase,
type: Schema.Literal("retry"),
- attempt: Schema.Number,
- // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate.
- error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }),
+ attempt: NonNegativeInt,
+ error: APIError.EffectSchema,
time: Schema.Struct({
- created: Schema.Number,
+ created: NonNegativeInt,
}),
})
.annotate({ identifier: "RetryPart" })
@@ -269,15 +268,15 @@ export const StepFinishPart = Schema.Struct({
type: Schema.Literal("step-finish"),
reason: Schema.String,
snapshot: Schema.optional(Schema.String),
- 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,
}),
}),
})
@@ -300,7 +299,7 @@ export const ToolStateRunning = Schema.Struct({
title: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
- start: Schema.Number,
+ start: NonNegativeInt,
}),
})
.annotate({ identifier: "ToolStateRunning" })
@@ -314,9 +313,9 @@ export const ToolStateCompleted = Schema.Struct({
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Any),
time: Schema.Struct({
- start: Schema.Number,
- end: Schema.Number,
- compacted: Schema.optional(Schema.Number),
+ start: NonNegativeInt,
+ end: NonNegativeInt,
+ compacted: Schema.optional(NonNegativeInt),
}),
attachments: Schema.optional(Schema.Array(FilePart)),
})
@@ -336,8 +335,8 @@ export const ToolStateError = Schema.Struct({
error: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
- start: Schema.Number,
- end: Schema.Number,
+ start: NonNegativeInt,
+ end: NonNegativeInt,
}),
})
.annotate({ identifier: "ToolStateError" })
@@ -380,7 +379,7 @@ export const User = Schema.Struct({
...messageBase,
role: Schema.Literal("user"),
time: Schema.Struct({
- created: Schema.Number,
+ created: NonNegativeInt,
}),
format: Schema.optional(_Format),
summary: Schema.optional(
@@ -447,9 +446,7 @@ export type Part =
| RetryPart
| CompactionPart
-// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived
-// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the
-// error classes to Schema.TaggedErrorClass is a separate slice.
+// Zod discriminated union kept for the legacy Hono OpenAPI path.
const AssistantErrorZod = z.discriminatedUnion("name", [
AuthError.Schema,
NamedError.Unknown.Schema,
@@ -461,6 +458,17 @@ const AssistantErrorZod = z.discriminatedUnion("name", [
])
type AssistantError = z.infer<typeof AssistantErrorZod>
+// 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 b1b245343..9d67c4868 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<typeof ToolCall>
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<typeof ToolPartialCall>
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 c376c8d1a..1be5dfffd 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 fdd561b4a..a0e57afc2 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 cd28377aa..ea30f5afc 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 af18d88b3..5b2df1e89 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 ad899531b..67bc9b9e7 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 82f6e5aae..c32c3963b 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 e10d21175..2753732dd 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 828beeefe..3a555c2ce 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 0f528b8f6..fb386f579 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 e144f2f90..d9b92a23c 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<Tag extends string, Fields extends Schema.Struc
})
.meta({ ref: tag })
+ // Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
+ const effectSchema = Schema.Struct({
+ name: Schema.Literal(tag),
+ data: dataSchema,
+ }).annotate({ identifier: tag })
+
type Data = Schema.Schema.Type<typeof dataSchema>
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 2a6c02349..380225316 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 b261d8b5b..66576a688 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<AssistantReasoning>("Sessio
}) {}
export class AssistantRetry extends Schema.Class<AssistantRetry>("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<Assistant>("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 f922becf3..aaf71c8dc 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<Source>("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<RetryError>("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<Retried>("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 7a7105dfa..a0324cce3 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<string, Partial<Record<(typeof
)
}
-function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
+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<string, unknown>
+ }
+ paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>>
+}
+
+type OpenApiSchema = {
+ $ref?: string
+ allOf?: unknown[]
+ anyOf?: unknown[]
+ oneOf?: unknown[]
+ properties?: Record<string, unknown>
+ type?: string | string[]
+}
+
type Operation = {
parameters?: unknown[]
responses?: unknown
@@ -74,7 +90,7 @@ type Operation = {
}
type RequestBody = {
- content?: Record<string, { schema?: { $ref?: string; type?: string } }>
+ content?: Record<string, { schema?: OpenApiSchema }>
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<string, Partial<Record<(typeof methods)[number], Operation>>> }
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 3978631b8..a4b0b6619 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 f9e94eeaa..b7425007e 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 4ab1da11e..8e48284de 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 555c717cf..b88a032f5 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<typeof app>
diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts
index bb6635b52..e34886652 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<typeof app>
diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts
index 8d03311d9..5e8ff01a0 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 87e2a9412..37d2a4f64 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 d02285946..c0984170b 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<typeof createOpencodeClient>
+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<string, string> },
+) {
+ 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<SdkResult>) {
+ 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<string, unknown>) : {}
+}
+
+function array(value: unknown) {
+ return Array.isArray(value) ? value : []
+}
+
+function statuses(input: Record<string, Awaited<ReturnType<typeof capture>>>) {
+ 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<A, E>(directory: string, effect: Effect.Effect<A, E, SessionNs.Service>) {
+ 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<T>(scenario: (backend: Backend) => Promise<T>) {
+ const legacy = await scenario("legacy")
+ await Instance.disposeAll()
+ await resetDatabase()
+ const httpapi = await scenario("httpapi")
+ expect(httpapi).toEqual(legacy)
+}
+
+async function withTmp<T>(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise<T>) {
+ 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<T>(
+ backend: Backend,
+ fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise<T>,
+) {
+ 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<Parameters<Sdk["part"]["update"]>[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 3e3fb3573..593f9765c 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 275819105..5fa6784a1 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<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts
index 81a210509..9f7c8e9e8 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 cb549c649..f43010571 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 b20665b34..02de54406 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": [