Procházet zdrojové kódy

Prepare Effect HttpApi backend parity (#24853)

Kit Langton před 3 měsíci
rodič
revize
6015084fa2
98 změnil soubory, kde provedl 4294 přidání a 2770 odebrání
  1. 52 0
      packages/opencode/scripts/diff-sdk-types.sh
  2. 8 0
      packages/opencode/specs/effect/http-api.md
  3. 3 3
      packages/opencode/src/agent/agent.ts
  4. 2 1
      packages/opencode/src/auth/index.ts
  5. 12 0
      packages/opencode/src/bus/bus-event.ts
  6. 2 1
      packages/opencode/src/cli/cmd/tui/event.ts
  7. 2 2
      packages/opencode/src/config/agent.ts
  8. 2 1
      packages/opencode/src/config/console-state.ts
  9. 3 3
      packages/opencode/src/config/mcp.ts
  10. 11 11
      packages/opencode/src/config/provider.ts
  11. 7 7
      packages/opencode/src/file/index.ts
  12. 14 14
      packages/opencode/src/file/ripgrep.ts
  13. 5 5
      packages/opencode/src/lsp/lsp.ts
  14. 4 4
      packages/opencode/src/project/project.ts
  15. 3 3
      packages/opencode/src/project/vcs.ts
  16. 2 2
      packages/opencode/src/provider/auth.ts
  17. 11 11
      packages/opencode/src/provider/models.ts
  18. 9 9
      packages/opencode/src/provider/provider.ts
  19. 5 5
      packages/opencode/src/pty/index.ts
  20. 32 0
      packages/opencode/src/server/backend.ts
  21. 12 11
      packages/opencode/src/server/middleware.ts
  22. 4 4
      packages/opencode/src/server/proxy.ts
  23. 54 0
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  24. 9 2
      packages/opencode/src/server/routes/instance/httpapi/event.ts
  25. 0 259
      packages/opencode/src/server/routes/instance/httpapi/global.ts
  26. 9 35
      packages/opencode/src/server/routes/instance/httpapi/groups/config.ts
  27. 11 35
      packages/opencode/src/server/routes/instance/httpapi/groups/control.ts
  28. 32 171
      packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
  29. 16 62
      packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
  30. 106 0
      packages/opencode/src/server/routes/instance/httpapi/groups/global.ts
  31. 15 82
      packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts
  32. 28 87
      packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
  33. 18 0
      packages/opencode/src/server/routes/instance/httpapi/groups/metadata.ts
  34. 14 30
      packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
  35. 17 49
      packages/opencode/src/server/routes/instance/httpapi/groups/project.ts
  36. 74 0
      packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts
  37. 121 0
      packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts
  38. 17 35
      packages/opencode/src/server/routes/instance/httpapi/groups/question.ts
  39. 428 0
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  40. 90 0
      packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts
  41. 164 0
      packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts
  42. 103 0
      packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
  43. 34 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts
  44. 34 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts
  45. 155 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
  46. 54 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
  47. 156 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
  48. 79 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts
  49. 68 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts
  50. 29 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts
  51. 46 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts
  52. 89 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts
  53. 118 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts
  54. 33 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/question.ts
  55. 49 442
      packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  56. 54 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts
  57. 134 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts
  58. 66 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
  59. 191 0
      packages/opencode/src/server/routes/instance/httpapi/instance-context.ts
  60. 3 14
      packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts
  61. 0 157
      packages/opencode/src/server/routes/instance/httpapi/provider.ts
  62. 0 242
      packages/opencode/src/server/routes/instance/httpapi/pty.ts
  63. 354 94
      packages/opencode/src/server/routes/instance/httpapi/public.ts
  64. 84 116
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  65. 0 137
      packages/opencode/src/server/routes/instance/httpapi/sync.ts
  66. 0 291
      packages/opencode/src/server/routes/instance/httpapi/tui.ts
  67. 0 166
      packages/opencode/src/server/routes/instance/httpapi/workspace.ts
  68. 34 7
      packages/opencode/src/server/server.ts
  69. 14 9
      packages/opencode/src/server/workspace.ts
  70. 56 48
      packages/opencode/src/session/message-v2.ts
  71. 14 14
      packages/opencode/src/session/message.ts
  72. 14 14
      packages/opencode/src/session/session.ts
  73. 3 3
      packages/opencode/src/session/status.ts
  74. 3 3
      packages/opencode/src/snapshot/index.ts
  75. 3 2
      packages/opencode/src/storage/storage.ts
  76. 16 0
      packages/opencode/src/sync/index.ts
  77. 2 1
      packages/opencode/src/tool/bash.ts
  78. 1 1
      packages/opencode/src/tool/codesearch.ts
  79. 6 6
      packages/opencode/src/tool/lsp.ts
  80. 3 2
      packages/opencode/src/tool/read.ts
  81. 7 0
      packages/opencode/src/util/named-schema-error.ts
  82. 2 0
      packages/opencode/src/util/schema.ts
  83. 8 7
      packages/opencode/src/v2/session-entry.ts
  84. 11 11
      packages/opencode/src/v2/session-event.ts
  85. 42 8
      packages/opencode/test/server/httpapi-bridge.test.ts
  86. 1 1
      packages/opencode/test/server/httpapi-experimental.test.ts
  87. 1 1
      packages/opencode/test/server/httpapi-file.test.ts
  88. 1 1
      packages/opencode/test/server/httpapi-instance.test.ts
  89. 3 3
      packages/opencode/test/server/httpapi-json-parity.test.ts
  90. 2 2
      packages/opencode/test/server/httpapi-mcp.test.ts
  91. 1 1
      packages/opencode/test/server/httpapi-provider.test.ts
  92. 1 1
      packages/opencode/test/server/httpapi-pty.test.ts
  93. 556 10
      packages/opencode/test/server/httpapi-sdk.test.ts
  94. 1 1
      packages/opencode/test/server/httpapi-session.test.ts
  95. 2 2
      packages/opencode/test/server/httpapi-sync.test.ts
  96. 1 1
      packages/opencode/test/server/httpapi-tui.test.ts
  97. 114 2
      packages/opencode/test/server/httpapi-workspace.test.ts
  98. 10 5
      packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap

+ 52 - 0
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

+ 8 - 0
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:

+ 3 - 3
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) })))

+ 2 - 1
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),
 }) {}

+ 12 - 0
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"

+ 2 - 1
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",
       }),
     }),

+ 2 - 2
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",

+ 2 - 1
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)
 }

+ 3 - 3
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.",
   }),
 })

+ 11 - 11
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(

+ 7 - 7
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),
 })
 

+ 14 - 14
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,
   }),
 })

+ 5 - 5
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,
 })

+ 4 - 4
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({

+ 3 - 3
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" })

+ 2 - 2
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>

+ 11 - 11
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({

+ 9 - 9
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({

+ 5 - 5
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 })),
 }
 

+ 32 - 0
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",
+  }
+}

+ 12 - 11
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 {

+ 4 - 4
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)
   }

+ 54 - 0
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

+ 9 - 2
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"))),
       ),

+ 0 - 259
packages/opencode/src/server/routes/instance/httpapi/global.ts

@@ -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)
-  }),
-)

+ 9 - 35
packages/opencode/src/server/routes/instance/httpapi/config.ts → 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)
-  }),
-)

+ 11 - 35
packages/opencode/src/server/routes/instance/httpapi/control.ts → 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)
-  }),
-)

+ 32 - 171
packages/opencode/src/server/routes/instance/httpapi/experimental.ts → 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)
-  }),
-)

+ 16 - 62
packages/opencode/src/server/routes/instance/httpapi/file.ts → 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)
-  }),
-)

+ 106 - 0
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." })),
+)

+ 15 - 82
packages/opencode/src/server/routes/instance/httpapi/instance.ts → 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)
-  }),
-)

+ 28 - 87
packages/opencode/src/server/routes/instance/httpapi/mcp.ts → 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)
-  }),
-)

+ 18 - 0
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
+    },
+  })
+}

+ 14 - 30
packages/opencode/src/server/routes/instance/httpapi/permission.ts → 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)
-  }),
-)

+ 17 - 49
packages/opencode/src/server/routes/instance/httpapi/project.ts → 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)
-  }),
-)

+ 74 - 0
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.",
+    }),
+  )

+ 121 - 0
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." })),
+)

+ 17 - 35
packages/opencode/src/server/routes/instance/httpapi/question.ts → 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)
-  }),
-)

+ 428 - 0
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.",
+    }),
+  )

+ 90 - 0
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.",
+    }),
+  )

+ 164 - 0
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.",
+    }),
+  )

+ 103 - 0
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.",
+    }),
+  )

+ 34 - 0
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)
+    }),
+)

+ 34 - 0
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)
+    }),
+)

+ 155 - 0
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)
+    }),
+)

+ 54 - 0
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)
+    }),
+)

+ 156 - 0
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)
+    }),
+)

+ 79 - 0
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)
+    }),
+)

+ 68 - 0
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)
+    }),
+)

+ 29 - 0
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)
+    }),
+)

+ 46 - 0
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)
+    }),
+)

+ 89 - 0
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)
+    }),
+)

+ 118 - 0
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)),
+)

+ 33 - 0
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)
+    }),
+)

+ 49 - 442
packages/opencode/src/server/routes/instance/httpapi/session.ts → 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 })),
       )
     })
 

+ 54 - 0
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)
+  }),
+)

+ 134 - 0
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)
+  }),
+)

+ 66 - 0
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)
+  }),
+)

+ 191 - 0
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

+ 3 - 14
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
   })

+ 0 - 157
packages/opencode/src/server/routes/instance/httpapi/provider.ts

@@ -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)
-  }),
-)

+ 0 - 242
packages/opencode/src/server/routes/instance/httpapi/pty.ts

@@ -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)),
-)

+ 354 - 94
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",

+ 84 - 116
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, {

+ 0 - 137
packages/opencode/src/server/routes/instance/httpapi/sync.ts

@@ -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)
-  }),
-)

+ 0 - 291
packages/opencode/src/server/routes/instance/httpapi/tui.ts

@@ -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)
-  }),
-)

+ 0 - 166
packages/opencode/src/server/routes/instance/httpapi/workspace.ts

@@ -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)
-  }),
-)

+ 34 - 7
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())

+ 14 - 9
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,

+ 56 - 48
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
 

+ 14 - 14
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,
           }),
         }),
       }),

+ 14 - 14
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({

+ 3 - 3
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"),

+ 3 - 3
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" })

+ 3 - 2
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({

+ 16 - 0
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 "."

+ 2 - 1
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.`,
   }),

+ 1 - 1
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({

+ 6 - 6
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.",
   }),

+ 3 - 2
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)",
   }),
 })

+ 7 - 0
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

+ 2 - 0
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`.

+ 8 - 7
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),

+ 11 - 11
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 }) {

+ 42 - 8
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())

+ 1 - 1
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"

+ 1 - 1
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"

+ 1 - 1
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"

+ 3 - 3
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>
 

+ 2 - 2
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>
 

+ 1 - 1
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: {

+ 1 - 1
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"

+ 556 - 10
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,
+        },
+      }
+    })
+  })
 })

+ 1 - 1
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"

+ 2 - 2
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>) {

+ 1 - 1
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"

+ 114 - 2
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)
+    }
+  })
 })

+ 10 - 5
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": [