Explorar el Código

Prepare Effect HttpApi backend parity (#24853)

Kit Langton hace 3 meses
padre
commit
6015084fa2
Se han modificado 98 ficheros con 4294 adiciones y 2770 borrados
  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.
 - 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.
 - 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
 ### 5. Make HttpApi Default For JSON Routes
 
 
 After JSON parity and SDK generation are covered:
 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"]),
   mode: Schema.Literals(["subagent", "primary", "all"]),
   native: Schema.optional(Schema.Boolean),
   native: Schema.optional(Schema.Boolean),
   hidden: 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),
   color: Schema.optional(Schema.String),
   permission: Permission.Ruleset,
   permission: Permission.Ruleset,
   model: Schema.optional(
   model: Schema.optional(
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
   variant: Schema.optional(Schema.String),
   variant: Schema.optional(Schema.String),
   prompt: Schema.optional(Schema.String),
   prompt: Schema.optional(Schema.String),
   options: Schema.Record(Schema.String, Schema.Unknown),
   options: Schema.Record(Schema.String, Schema.Unknown),
-  steps: Schema.optional(Schema.Number),
+  steps: Schema.optional(Schema.Finite),
 })
 })
   .annotate({ identifier: "Agent" })
   .annotate({ identifier: "Agent" })
   .pipe(withStatics((s) => ({ zod: zod(s) })))
   .pipe(withStatics((s) => ({ zod: zod(s) })))

+ 2 - 1
packages/opencode/src/auth/index.ts

@@ -1,6 +1,7 @@
 import path from "path"
 import path from "path"
 import { Effect, Layer, Record, Result, Schema, Context } from "effect"
 import { Effect, Layer, Record, Result, Schema, Context } from "effect"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
+import { NonNegativeInt } from "@/util/schema"
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 
 
@@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
   type: Schema.Literal("oauth"),
   type: Schema.Literal("oauth"),
   refresh: Schema.String,
   refresh: Schema.String,
   access: Schema.String,
   access: Schema.String,
-  expires: Schema.Number,
+  expires: NonNegativeInt,
   accountId: Schema.optional(Schema.String),
   accountId: Schema.optional(Schema.String),
   enterpriseUrl: 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()
     .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"
 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 { BusEvent } from "@/bus/bus-event"
 import { SessionID } from "@/session/schema"
 import { SessionID } from "@/session/schema"
+import { PositiveInt } from "@/util/schema"
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
 
 
 const DEFAULT_TOAST_DURATION = 5000
 const DEFAULT_TOAST_DURATION = 5000
@@ -38,7 +39,7 @@ export const TuiEvent = {
       title: Schema.optional(Schema.String),
       title: Schema.optional(Schema.String),
       message: Schema.String,
       message: Schema.String,
       variant: Schema.Literals(["info", "success", "warning", "error"]),
       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",
         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({
     variant: Schema.optional(Schema.String).annotate({
       description: "Default model variant for this agent (applies only when using the agent's configured model).",
       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),
     prompt: Schema.optional(Schema.String),
     tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
     tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
       description: "@deprecated Use 'permission' field instead",
       description: "@deprecated Use 'permission' field instead",

+ 2 - 1
packages/opencode/src/config/console-state.ts

@@ -1,10 +1,11 @@
 import { Schema } from "effect"
 import { Schema } from "effect"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
+import { NonNegativeInt } from "@/util/schema"
 
 
 export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
 export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
   consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
   consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
   activeOrgName: Schema.optional(Schema.String),
   activeOrgName: Schema.optional(Schema.String),
-  switchableOrgCount: Schema.Number,
+  switchableOrgCount: NonNegativeInt,
 }) {
 }) {
   static readonly zod = zod(this)
   static readonly zod = zod(this)
 }
 }

+ 3 - 3
packages/opencode/src/config/mcp.ts

@@ -1,6 +1,6 @@
 import { Schema } from "effect"
 import { Schema } from "effect"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { PositiveInt, withStatics } from "@/util/schema"
 
 
 export const Local = Schema.Struct({
 export const Local = Schema.Struct({
   type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
   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({
   enabled: Schema.optional(Schema.Boolean).annotate({
     description: "Enable or disable the MCP server on startup",
     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.",
     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({
   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.",
     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.",
     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(
   cost: Schema.optional(
     Schema.Struct({
     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(
       context_over_200k: Schema.optional(
         Schema.Struct({
         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(
   limit: Schema.optional(
     Schema.Struct({
     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(
   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 { Protected } from "./protected"
 import { Ripgrep } from "./ripgrep"
 import { Ripgrep } from "./ripgrep"
 import { zod } from "@/util/effect-zod"
 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({
 export const Info = Schema.Struct({
   path: Schema.String,
   path: Schema.String,
-  added: Schema.Int,
-  removed: Schema.Int,
+  added: NonNegativeInt,
+  removed: NonNegativeInt,
   status: Schema.Literals(["added", "deleted", "modified"]),
   status: Schema.Literals(["added", "deleted", "modified"]),
 })
 })
   .annotate({ identifier: "File" })
   .annotate({ identifier: "File" })
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
 export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
 export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
 
 
 const Hunk = Schema.Struct({
 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),
   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 { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
 import { which } from "@/util/which"
 import { which } from "@/util/which"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, withStatics } from "@/util/schema"
 
 
 const log = Log.create({ service: "ripgrep" })
 const log = Log.create({ service: "ripgrep" })
 const VERSION = "15.1.0"
 const VERSION = "15.1.0"
@@ -27,19 +27,19 @@ const PLATFORM = {
 } as const
 } as const
 
 
 const TimeStats = Schema.Struct({
 const TimeStats = Schema.Struct({
-  secs: Schema.Number,
-  nanos: Schema.Number,
+  secs: NonNegativeInt,
+  nanos: NonNegativeInt,
   human: Schema.String,
   human: Schema.String,
 })
 })
 
 
 const Stats = Schema.Struct({
 const Stats = Schema.Struct({
   elapsed: TimeStats,
   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({
 const PathText = Schema.Struct({
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
   lines: Schema.Struct({
   lines: Schema.Struct({
     text: Schema.String,
     text: Schema.String,
   }),
   }),
-  line_number: Schema.Number,
-  absolute_offset: Schema.Number,
+  line_number: NonNegativeInt,
+  absolute_offset: NonNegativeInt,
   submatches: Schema.Array(
   submatches: Schema.Array(
     Schema.Struct({
     Schema.Struct({
       match: Schema.Struct({
       match: Schema.Struct({
         text: Schema.String,
         text: Schema.String,
       }),
       }),
-      start: Schema.Number,
-      end: Schema.Number,
+      start: NonNegativeInt,
+      end: NonNegativeInt,
     }),
     }),
   ),
   ),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -80,7 +80,7 @@ const End = Schema.Struct({
   type: Schema.Literal("end"),
   type: Schema.Literal("end"),
   data: Schema.Struct({
   data: Schema.Struct({
     path: PathText,
     path: PathText,
-    binary_offset: Schema.NullOr(Schema.Number),
+    binary_offset: Schema.NullOr(NonNegativeInt),
     stats: Stats,
     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 { Effect, Layer, Context, Schema } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 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"
 import { zod, ZodOverride } from "@/util/effect-zod"
 
 
 const log = Log.create({ service: "lsp" })
 const log = Log.create({ service: "lsp" })
@@ -23,8 +23,8 @@ export const Event = {
 }
 }
 
 
 const Position = Schema.Struct({
 const Position = Schema.Struct({
-  line: Schema.Number,
-  character: Schema.Number,
+  line: NonNegativeInt,
+  character: NonNegativeInt,
 })
 })
 
 
 export const Range = Schema.Struct({
 export const Range = Schema.Struct({
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
 
 
 export const Symbol = Schema.Struct({
 export const Symbol = Schema.Struct({
   name: Schema.String,
   name: Schema.String,
-  kind: Schema.Number,
+  kind: NonNegativeInt,
   location: Schema.Struct({
   location: Schema.Struct({
     uri: Schema.String,
     uri: Schema.String,
     range: Range,
     range: Range,
@@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type
 export const DocumentSymbol = Schema.Struct({
 export const DocumentSymbol = Schema.Struct({
   name: Schema.String,
   name: Schema.String,
   detail: Schema.optional(Schema.String),
   detail: Schema.optional(Schema.String),
-  kind: Schema.Number,
+  kind: NonNegativeInt,
   range: Range,
   range: Range,
   selectionRange: 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 { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, withStatics } from "@/util/schema"
 
 
 const log = Log.create({ service: "project" })
 const log = Log.create({ service: "project" })
 
 
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
 })
 })
 
 
 const ProjectTime = 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({
 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 { Git } from "@/git"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, withStatics } from "@/util/schema"
 
 
 const log = Log.create({ service: "vcs" })
 const log = Log.create({ service: "vcs" })
 
 
@@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type<typeof Info>
 export const FileDiff = Schema.Struct({
 export const FileDiff = Schema.Struct({
   file: Schema.String,
   file: Schema.String,
   patch: Schema.String,
   patch: Schema.String,
-  additions: Schema.Number,
-  deletions: Schema.Number,
+  additions: NonNegativeInt,
+  deletions: NonNegativeInt,
   status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
   status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
 })
 })
   .annotate({ identifier: "VcsFileDiff" })
   .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({
 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" }),
   inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
 export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
 
 
 export const CallbackInput = Schema.Struct({
 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" }),
   code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
 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 ttl = 5 * 60 * 1000
 
 
 const Cost = Schema.Struct({
 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(
   context_over_200k: Schema.optional(
     Schema.Struct({
     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),
   cost: Schema.optional(Cost),
   limit: Schema.Struct({
   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(
   modalities: Schema.optional(
     Schema.Struct({
     Schema.Struct({

+ 9 - 9
packages/opencode/src/provider/provider.ts

@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
 })
 })
 
 
 const ProviderCacheCost = Schema.Struct({
 const ProviderCacheCost = Schema.Struct({
-  read: Schema.Number,
-  write: Schema.Number,
+  read: Schema.Finite,
+  write: Schema.Finite,
 })
 })
 
 
 const ProviderCost = Schema.Struct({
 const ProviderCost = Schema.Struct({
-  input: Schema.Number,
-  output: Schema.Number,
+  input: Schema.Finite,
+  output: Schema.Finite,
   cache: ProviderCacheCost,
   cache: ProviderCacheCost,
   experimentalOver200K: Schema.optional(
   experimentalOver200K: Schema.optional(
     Schema.Struct({
     Schema.Struct({
-      input: Schema.Number,
-      output: Schema.Number,
+      input: Schema.Finite,
+      output: Schema.Finite,
       cache: ProviderCacheCost,
       cache: ProviderCacheCost,
     }),
     }),
   ),
   ),
 })
 })
 
 
 const ProviderLimit = Schema.Struct({
 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({
 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 { PtyID } from "./schema"
 import { Effect, Layer, Context, Schema, Types } from "effect"
 import { Effect, Layer, Context, Schema, Types } from "effect"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
 
 
 const log = Log.create({ service: "pty" })
 const log = Log.create({ service: "pty" })
 
 
@@ -62,7 +62,7 @@ export const Info = Schema.Struct({
   args: Schema.Array(Schema.String),
   args: Schema.Array(Schema.String),
   cwd: Schema.String,
   cwd: Schema.String,
   status: Schema.Literals(["running", "exited"]),
   status: Schema.Literals(["running", "exited"]),
-  pid: Schema.Number,
+  pid: PositiveInt,
 })
 })
   .annotate({ identifier: "Pty" })
   .annotate({ identifier: "Pty" })
   .pipe(withStatics((s) => ({ zod: zod(s) })))
   .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({
   title: Schema.optional(Schema.String),
   title: Schema.optional(Schema.String),
   size: Schema.optional(
   size: Schema.optional(
     Schema.Struct({
     Schema.Struct({
-      rows: Schema.Number,
-      cols: Schema.Number,
+      rows: PositiveInt,
+      cols: PositiveInt,
     }),
     }),
   ),
   ),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInpu
 export const Event = {
 export const Event = {
   Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
   Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
   Updated: BusEvent.define("pty.updated", 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 })),
   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 { basicAuth } from "hono/basic-auth"
 import { cors } from "hono/cors"
 import { cors } from "hono/cors"
 import { compress } from "hono/compress"
 import { compress } from "hono/compress"
+import * as ServerBackend from "./backend"
 
 
 const log = Log.create({ service: "server" })
 const log = Log.create({ service: "server" })
 
 
@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
   return basicAuth({ username, password })(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,
       method: c.req.method,
       path: c.req.path,
       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 {
 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
   return out
 }
 }
 
 
-function protocols(req: Request) {
+export function websocketProtocols(req: Request) {
   const value = req.headers.get("sec-websocket-protocol")
   const value = req.headers.get("sec-websocket-protocol")
   if (!value) return []
   if (!value) return []
   return value
   return value
@@ -42,7 +42,7 @@ function protocols(req: Request) {
     .filter(Boolean)
     .filter(Boolean)
 }
 }
 
 
-function socket(url: string | URL) {
+export function websocketTargetURL(url: string | URL) {
   const next = new URL(url)
   const next = new URL(url)
   if (next.protocol === "http:") next.protocol = "ws:"
   if (next.protocol === "http:") next.protocol = "ws:"
   if (next.protocol === "https:") next.protocol = "wss:"
   if (next.protocol === "https:") next.protocol = "wss:"
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
             ws.close(1011, "missing proxy target")
             ws.close(1011, "missing proxy target")
             return
             return
           }
           }
-          remote = new WebSocket(url, protocols(c.req.raw))
+          remote = new WebSocket(url, websocketProtocols(c.req.raw))
           remote.binaryType = "arraybuffer"
           remote.binaryType = "arraybuffer"
           remote.onopen = () => {
           remote.onopen = () => {
             for (const item of queue) remote?.send(item)
             for (const item of queue) remote?.send(item)
@@ -150,7 +150,7 @@ export function websocket(
   proxy.pathname = "/__workspace_ws"
   proxy.pathname = "/__workspace_ws"
   proxy.search = ""
   proxy.search = ""
   const next = new Headers(req.headers)
   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()) {
   for (const [key, value] of new Headers(extra).entries()) {
     next.set(key, value)
     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 * as Stream from "effect/Stream"
 import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
 import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
 import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import * as Sse from "effect/unstable/encoding/Sse"
 
 
 const log = Log.create({ service: "server" })
 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." })),
     .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(
 export const eventRoute = HttpRouter.add(
@@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add(
       Stream.make({ type: "server.connected", properties: {} }).pipe(
       Stream.make({ type: "server.connected", properties: {} }).pipe(
         Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
         Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
         Stream.map(eventData),
         Stream.map(eventData),
+        Stream.pipeThroughChannel(Sse.encode()),
         Stream.encodeText,
         Stream.encodeText,
         Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
         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 { Config } from "@/config/config"
 import { Provider } from "@/provider/provider"
 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"
 const root = "/config"
 
 
@@ -13,7 +12,7 @@ export const ConfigApi = HttpApi.make("config")
     HttpApiGroup.make("config")
     HttpApiGroup.make("config")
       .add(
       .add(
         HttpApiEndpoint.get("get", root, {
         HttpApiEndpoint.get("get", root, {
-          success: Config.Info,
+          success: described(Config.Info, "Get config info"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "config.get",
             identifier: "config.get",
@@ -23,7 +22,8 @@ export const ConfigApi = HttpApi.make("config")
         ),
         ),
         HttpApiEndpoint.patch("update", root, {
         HttpApiEndpoint.patch("update", root, {
           payload: Config.Info,
           payload: Config.Info,
-          success: Config.Info,
+          success: described(Config.Info, "Successfully updated config"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "config.update",
             identifier: "config.update",
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("providers", `${root}/providers`, {
         HttpApiEndpoint.get("providers", `${root}/providers`, {
-          success: Provider.ConfigProvidersResult,
+          success: described(Provider.ConfigProvidersResult, "List of providers"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "config.providers",
             identifier: "config.providers",
@@ -47,6 +47,7 @@ export const ConfigApi = HttpApi.make("config")
           description: "Experimental HttpApi config routes.",
           description: "Experimental HttpApi config routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -56,30 +57,3 @@ export const ConfigApi = HttpApi.make("config")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { Auth } from "@/auth"
 import { ProviderID } from "@/provider/schema"
 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({
 const AuthParams = Schema.Struct({
   providerID: ProviderID,
   providerID: ProviderID,
@@ -13,7 +13,7 @@ const LogQuery = Schema.Struct({
   workspace: Schema.optional(Schema.String),
   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" }),
   service: Schema.String.annotate({ description: "Service name for the log entry" }),
   level: Schema.Union([
   level: Schema.Union([
     Schema.Literal("debug"),
     Schema.Literal("debug"),
@@ -25,7 +25,7 @@ const LogInput = Schema.Struct({
   extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
   extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
     description: "Additional metadata for the log entry",
     description: "Additional metadata for the log entry",
   }),
   }),
-}).annotate({ identifier: "AppLogInput" })
+})
 
 
 export const ControlPaths = {
 export const ControlPaths = {
   auth: "/auth/:providerID",
   auth: "/auth/:providerID",
@@ -38,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add(
       HttpApiEndpoint.put("authSet", ControlPaths.auth, {
       HttpApiEndpoint.put("authSet", ControlPaths.auth, {
         params: AuthParams,
         params: AuthParams,
         payload: Auth.Info,
         payload: Auth.Info,
-        success: Schema.Boolean,
+        success: described(Schema.Boolean, "Successfully set authentication credentials"),
+        error: HttpApiError.BadRequest,
       }).annotateMerge(
       }).annotateMerge(
         OpenApi.annotations({
         OpenApi.annotations({
           identifier: "auth.set",
           identifier: "auth.set",
@@ -48,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add(
       ),
       ),
       HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
       HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
         params: AuthParams,
         params: AuthParams,
-        success: Schema.Boolean,
+        success: described(Schema.Boolean, "Successfully removed authentication credentials"),
+        error: HttpApiError.BadRequest,
       }).annotateMerge(
       }).annotateMerge(
         OpenApi.annotations({
         OpenApi.annotations({
           identifier: "auth.remove",
           identifier: "auth.remove",
@@ -59,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add(
       HttpApiEndpoint.post("log", ControlPaths.log, {
       HttpApiEndpoint.post("log", ControlPaths.log, {
         query: LogQuery,
         query: LogQuery,
         payload: LogInput,
         payload: LogInput,
-        success: Schema.Boolean,
+        success: described(Schema.Boolean, "Log entry written successfully"),
+        error: HttpApiError.BadRequest,
       }).annotateMerge(
       }).annotateMerge(
         OpenApi.annotations({
         OpenApi.annotations({
           identifier: "app.log",
           identifier: "app.log",
@@ -70,30 +73,3 @@ export const ControlApi = HttpApi.make("control").add(
     )
     )
     .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
     .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 { 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 { MCP } from "@/mcp"
-import { Project } from "@/project/project"
 import { ProviderID, ModelID } from "@/provider/schema"
 import { ProviderID, ModelID } from "@/provider/schema"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
-import { ToolRegistry } from "@/tool/registry"
-import * as EffectZod from "@/util/effect-zod"
 import { Worktree } from "@/worktree"
 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({
 const ConsoleStateResponse = Schema.Struct({
   consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
   consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
   activeOrgName: Schema.optionalKey(Schema.String),
   activeOrgName: Schema.optionalKey(Schema.String),
-  switchableOrgCount: Schema.Number,
+  switchableOrgCount: NonNegativeInt,
 }).annotate({ identifier: "ConsoleState" })
 }).annotate({ identifier: "ConsoleState" })
 
 
 const ConsoleOrgOption = Schema.Struct({
 const ConsoleOrgOption = Schema.Struct({
@@ -28,25 +23,25 @@ const ConsoleOrgOption = Schema.Struct({
   orgID: Schema.String,
   orgID: Schema.String,
   orgName: Schema.String,
   orgName: Schema.String,
   active: Schema.Boolean,
   active: Schema.Boolean,
-}).annotate({ identifier: "ConsoleOrgOption" })
+})
 
 
 const ConsoleOrgList = Schema.Struct({
 const ConsoleOrgList = Schema.Struct({
   orgs: Schema.Array(ConsoleOrgOption),
   orgs: Schema.Array(ConsoleOrgOption),
-}).annotate({ identifier: "ConsoleOrgList" })
+})
 
 
-const ConsoleSwitchPayload = Schema.Struct({
+export const ConsoleSwitchPayload = Schema.Struct({
   accountID: AccountID,
   accountID: AccountID,
   orgID: OrgID,
   orgID: OrgID,
-}).annotate({ identifier: "ConsoleSwitchInput" })
+})
 
 
 const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
 const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
 const ToolListItem = Schema.Struct({
 const ToolListItem = Schema.Struct({
   id: Schema.String,
   id: Schema.String,
   description: Schema.String,
   description: Schema.String,
-  parameters: Schema.Record(Schema.String, Schema.Any),
+  parameters: Schema.Unknown,
 }).annotate({ identifier: "ToolListItem" })
 }).annotate({ identifier: "ToolListItem" })
 const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
 const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
-const ToolListQuery = Schema.Struct({
+export const ToolListQuery = Schema.Struct({
   provider: ProviderID,
   provider: ProviderID,
   model: ModelID,
   model: ModelID,
 })
 })
@@ -57,8 +52,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
     encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
     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),
   directory: Schema.optional(Schema.String),
   roots: Schema.optional(QueryBoolean),
   roots: Schema.optional(QueryBoolean),
   start: Schema.optional(Schema.NumberFromString),
   start: Schema.optional(Schema.NumberFromString),
@@ -85,7 +80,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
     HttpApiGroup.make("experimental")
     HttpApiGroup.make("experimental")
       .add(
       .add(
         HttpApiEndpoint.get("console", ExperimentalPaths.console, {
         HttpApiEndpoint.get("console", ExperimentalPaths.console, {
-          success: ConsoleStateResponse,
+          success: described(ConsoleStateResponse, "Active Console provider metadata"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "experimental.console.get",
             identifier: "experimental.console.get",
@@ -94,7 +89,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
         HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
-          success: ConsoleOrgList,
+          success: described(ConsoleOrgList, "Switchable Console orgs"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "experimental.console.listOrgs",
             identifier: "experimental.console.listOrgs",
@@ -104,7 +99,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
         HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
           payload: ConsoleSwitchPayload,
           payload: ConsoleSwitchPayload,
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "Switch success"),
           error: HttpApiError.BadRequest,
           error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
@@ -115,7 +110,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
         HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
           query: ToolListQuery,
           query: ToolListQuery,
-          success: ToolList,
+          success: described(ToolList, "Tools"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "tool.list",
             identifier: "tool.list",
@@ -125,7 +121,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
         HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
-          success: ToolIDs,
+          success: described(ToolIDs, "Tool IDs"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "tool.ids",
             identifier: "tool.ids",
@@ -135,7 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
         HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
-          success: WorktreeList,
+          success: described(WorktreeList, "List of worktree directories"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "worktree.list",
             identifier: "worktree.list",
@@ -145,7 +142,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
         HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
           payload: Schema.optional(Worktree.CreateInput),
           payload: Schema.optional(Worktree.CreateInput),
-          success: Worktree.Info,
+          success: described(Worktree.Info, "Worktree created"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "worktree.create",
             identifier: "worktree.create",
@@ -155,7 +153,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
         HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
           payload: Worktree.RemoveInput,
           payload: Worktree.RemoveInput,
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "Worktree removed"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "worktree.remove",
             identifier: "worktree.remove",
@@ -165,7 +164,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
         HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
           payload: Worktree.ResetInput,
           payload: Worktree.ResetInput,
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "Worktree reset"),
+          error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "worktree.reset",
             identifier: "worktree.reset",
@@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
         ),
         ),
         HttpApiEndpoint.get("session", ExperimentalPaths.session, {
         HttpApiEndpoint.get("session", ExperimentalPaths.session, {
           query: SessionListQuery,
           query: SessionListQuery,
-          success: Schema.Array(Session.GlobalInfo),
+          success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "experimental.session.list",
             identifier: "experimental.session.list",
@@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
         HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
-          success: Schema.Record(Schema.String, MCP.Resource),
+          success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "experimental.resource.list",
             identifier: "experimental.resource.list",
@@ -200,6 +200,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
           description: "Experimental HttpApi read-only routes.",
           description: "Experimental HttpApi read-only routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -209,143 +210,3 @@ export const ExperimentalApi = HttpApi.make("experimental")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { File } from "@/file"
 import { Ripgrep } from "@/file/ripgrep"
 import { Ripgrep } from "@/file/ripgrep"
-import * as InstanceState from "@/effect/instance-state"
 import { LSP } from "@/lsp/lsp"
 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,
   path: Schema.String,
 })
 })
 
 
-const FindTextQuery = Schema.Struct({
+export const FindTextQuery = Schema.Struct({
   pattern: Schema.String,
   pattern: Schema.String,
 })
 })
 
 
-const FindFileQuery = Schema.Struct({
+export const FindFileQuery = Schema.Struct({
   query: Schema.String,
   query: Schema.String,
   dirs: Schema.optional(Schema.Literals(["true", "false"])),
   dirs: Schema.optional(Schema.Literals(["true", "false"])),
   type: Schema.optional(Schema.Literals(["file", "directory"])),
   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,
   query: Schema.String,
 })
 })
 
 
@@ -42,7 +43,7 @@ export const FileApi = HttpApi.make("file")
       .add(
       .add(
         HttpApiEndpoint.get("findText", FilePaths.findText, {
         HttpApiEndpoint.get("findText", FilePaths.findText, {
           query: FindTextQuery,
           query: FindTextQuery,
-          success: Schema.Array(Ripgrep.SearchMatch),
+          success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "find.text",
             identifier: "find.text",
@@ -52,7 +53,7 @@ export const FileApi = HttpApi.make("file")
         ),
         ),
         HttpApiEndpoint.get("findFile", FilePaths.findFile, {
         HttpApiEndpoint.get("findFile", FilePaths.findFile, {
           query: FindFileQuery,
           query: FindFileQuery,
-          success: Schema.Array(Schema.String),
+          success: described(Schema.Array(Schema.String), "File paths"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "find.files",
             identifier: "find.files",
@@ -62,7 +63,7 @@ export const FileApi = HttpApi.make("file")
         ),
         ),
         HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
         HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
           query: FindSymbolQuery,
           query: FindSymbolQuery,
-          success: Schema.Array(LSP.Symbol),
+          success: described(Schema.Array(LSP.Symbol), "Symbols"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "find.symbols",
             identifier: "find.symbols",
@@ -72,7 +73,7 @@ export const FileApi = HttpApi.make("file")
         ),
         ),
         HttpApiEndpoint.get("list", FilePaths.list, {
         HttpApiEndpoint.get("list", FilePaths.list, {
           query: FileQuery,
           query: FileQuery,
-          success: Schema.Array(File.Node),
+          success: described(Schema.Array(File.Node), "Files and directories"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "file.list",
             identifier: "file.list",
@@ -82,7 +83,7 @@ export const FileApi = HttpApi.make("file")
         ),
         ),
         HttpApiEndpoint.get("content", FilePaths.content, {
         HttpApiEndpoint.get("content", FilePaths.content, {
           query: FileQuery,
           query: FileQuery,
-          success: File.Content,
+          success: described(File.Content, "File content"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "file.read",
             identifier: "file.read",
@@ -91,7 +92,7 @@ export const FileApi = HttpApi.make("file")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("status", FilePaths.status, {
         HttpApiEndpoint.get("status", FilePaths.status, {
-          success: Schema.Array(File.Info),
+          success: described(Schema.Array(File.Info), "File status"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "file.status",
             identifier: "file.status",
@@ -106,6 +107,7 @@ export const FileApi = HttpApi.make("file")
           description: "Experimental HttpApi file routes.",
           description: "Experimental HttpApi file routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -115,51 +117,3 @@ export const FileApi = HttpApi.make("file")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { Agent } from "@/agent/agent"
 import { Command } from "@/command"
 import { Command } from "@/command"
 import { Format } from "@/format"
 import { Format } from "@/format"
-import { Global } from "@opencode-ai/core/global"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
 import { Vcs } from "@/project/vcs"
 import { Vcs } from "@/project/vcs"
 import { Skill } from "@/skill"
 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({
 const PathInfo = Schema.Struct({
   home: Schema.String,
   home: Schema.String,
@@ -19,7 +18,7 @@ const PathInfo = Schema.Struct({
   directory: Schema.String,
   directory: Schema.String,
 }).annotate({ identifier: "Path" })
 }).annotate({ identifier: "Path" })
 
 
-const VcsDiffQuery = Schema.Struct({
+export const VcsDiffQuery = Schema.Struct({
   mode: Vcs.Mode,
   mode: Vcs.Mode,
 })
 })
 
 
@@ -40,7 +39,7 @@ export const InstanceApi = HttpApi.make("instance")
     HttpApiGroup.make("instance")
     HttpApiGroup.make("instance")
       .add(
       .add(
         HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
         HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "Instance disposed"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "instance.dispose",
             identifier: "instance.dispose",
@@ -59,7 +58,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
         HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
-          success: Vcs.Info,
+          success: described(Vcs.Info, "VCS info"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "vcs.get",
             identifier: "vcs.get",
@@ -70,7 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
         ),
         ),
         HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
         HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
           query: VcsDiffQuery,
           query: VcsDiffQuery,
-          success: Schema.Array(Vcs.FileDiff),
+          success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "vcs.diff",
             identifier: "vcs.diff",
@@ -79,7 +78,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("command", InstancePaths.command, {
         HttpApiEndpoint.get("command", InstancePaths.command, {
-          success: Schema.Array(Command.Info),
+          success: described(Schema.Array(Command.Info), "List of commands"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "command.list",
             identifier: "command.list",
@@ -88,7 +87,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("agent", InstancePaths.agent, {
         HttpApiEndpoint.get("agent", InstancePaths.agent, {
-          success: Schema.Array(Agent.Info),
+          success: described(Schema.Array(Agent.Info), "List of agents"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "app.agents",
             identifier: "app.agents",
@@ -97,7 +96,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("skill", InstancePaths.skill, {
         HttpApiEndpoint.get("skill", InstancePaths.skill, {
-          success: Schema.Array(Skill.Info),
+          success: described(Schema.Array(Skill.Info), "List of skills"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "app.skills",
             identifier: "app.skills",
@@ -106,7 +105,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
         HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
-          success: Schema.Array(LSP.Status),
+          success: described(Schema.Array(LSP.Status), "LSP server status"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "lsp.status",
             identifier: "lsp.status",
@@ -115,7 +114,7 @@ export const InstanceApi = HttpApi.make("instance")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
         HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
-          success: Schema.Array(Format.Status),
+          success: described(Schema.Array(Format.Status), "Formatter status"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "formatter.status",
             identifier: "formatter.status",
@@ -130,6 +129,7 @@ export const InstanceApi = HttpApi.make("instance")
           description: "Experimental HttpApi instance read routes.",
           description: "Experimental HttpApi instance read routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -139,70 +139,3 @@ export const InstanceApi = HttpApi.make("instance")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { MCP } from "@/mcp"
 import { ConfigMCP } from "@/config/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,
   name: Schema.String,
   config: ConfigMCP.Info,
   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,
   authorizationUrl: Schema.String,
-  oauthState: Schema.String,
-}).annotate({ identifier: "McpAuthStartResponse" })
-const AuthCallbackPayload = Schema.Struct({
+})
+export const AuthCallbackPayload = Schema.Struct({
   code: Schema.String,
   code: Schema.String,
-}).annotate({ identifier: "McpAuthCallbackInput" })
-const AuthRemoveResponse = Schema.Struct({
+})
+export const AuthRemoveResponse = Schema.Struct({
   success: Schema.Literal(true),
   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 },
   { error: Schema.String },
   { httpApiStatus: 400 },
   { httpApiStatus: 400 },
 ) {}
 ) {}
@@ -39,7 +40,7 @@ export const McpApi = HttpApi.make("mcp")
     HttpApiGroup.make("mcp")
     HttpApiGroup.make("mcp")
       .add(
       .add(
         HttpApiEndpoint.get("status", McpPaths.status, {
         HttpApiEndpoint.get("status", McpPaths.status, {
-          success: Schema.Record(Schema.String, MCP.Status),
+          success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.status",
             identifier: "mcp.status",
@@ -49,7 +50,7 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.post("add", McpPaths.status, {
         HttpApiEndpoint.post("add", McpPaths.status, {
           payload: AddPayload,
           payload: AddPayload,
-          success: StatusMap,
+          success: described(StatusMap, "MCP server added successfully"),
           error: HttpApiError.BadRequest,
           error: HttpApiError.BadRequest,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
@@ -60,8 +61,8 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.post("authStart", McpPaths.auth, {
         HttpApiEndpoint.post("authStart", McpPaths.auth, {
           params: { name: Schema.String },
           params: { name: Schema.String },
-          success: AuthStartResponse,
-          error: UnsupportedOAuthError,
+          success: described(AuthStartResponse, "OAuth flow started"),
+          error: [UnsupportedOAuthError, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.auth.start",
             identifier: "mcp.auth.start",
@@ -72,7 +73,8 @@ export const McpApi = HttpApi.make("mcp")
         HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
         HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
           params: { name: Schema.String },
           params: { name: Schema.String },
           payload: AuthCallbackPayload,
           payload: AuthCallbackPayload,
-          success: MCP.Status,
+          success: described(MCP.Status, "OAuth authentication completed"),
+          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.auth.callback",
             identifier: "mcp.auth.callback",
@@ -83,8 +85,8 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
         HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
           params: { name: Schema.String },
           params: { name: Schema.String },
-          success: MCP.Status,
-          error: UnsupportedOAuthError,
+          success: described(MCP.Status, "OAuth authentication completed"),
+          error: [UnsupportedOAuthError, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.auth.authenticate",
             identifier: "mcp.auth.authenticate",
@@ -94,7 +96,8 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
         HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
           params: { name: Schema.String },
           params: { name: Schema.String },
-          success: AuthRemoveResponse,
+          success: described(AuthRemoveResponse, "OAuth credentials removed"),
+          error: HttpApiError.NotFound,
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.auth.remove",
             identifier: "mcp.auth.remove",
@@ -104,7 +107,7 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.post("connect", McpPaths.connect, {
         HttpApiEndpoint.post("connect", McpPaths.connect, {
           params: { name: Schema.String },
           params: { name: Schema.String },
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "MCP server connected successfully"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.connect",
             identifier: "mcp.connect",
@@ -113,7 +116,7 @@ export const McpApi = HttpApi.make("mcp")
         ),
         ),
         HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
         HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
           params: { name: Schema.String },
           params: { name: Schema.String },
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "MCP server disconnected successfully"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "mcp.disconnect",
             identifier: "mcp.disconnect",
@@ -127,6 +130,7 @@ export const McpApi = HttpApi.make("mcp")
           description: "Experimental HttpApi MCP routes.",
           description: "Experimental HttpApi MCP routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -136,66 +140,3 @@ export const McpApi = HttpApi.make("mcp")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 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 root = "/permission"
+const ReplyPayload = Schema.Struct({
+  reply: Permission.Reply,
+  message: Schema.optional(Schema.String),
+})
 
 
 export const PermissionApi = HttpApi.make("permission")
 export const PermissionApi = HttpApi.make("permission")
   .add(
   .add(
     HttpApiGroup.make("permission")
     HttpApiGroup.make("permission")
       .add(
       .add(
         HttpApiEndpoint.get("list", root, {
         HttpApiEndpoint.get("list", root, {
-          success: Schema.Array(Permission.Request),
+          success: described(Schema.Array(Permission.Request), "List of pending permissions"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "permission.list",
             identifier: "permission.list",
@@ -21,8 +27,9 @@ export const PermissionApi = HttpApi.make("permission")
         ),
         ),
         HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
         HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
           params: { requestID: PermissionID },
           params: { requestID: PermissionID },
-          payload: Permission.ReplyBody,
-          success: Schema.Boolean,
+          payload: ReplyPayload,
+          success: described(Schema.Boolean, "Permission processed successfully"),
+          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "permission.reply",
             identifier: "permission.reply",
@@ -37,6 +44,7 @@ export const PermissionApi = HttpApi.make("permission")
           description: "Experimental HttpApi permission routes.",
           description: "Experimental HttpApi permission routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -46,27 +54,3 @@ export const PermissionApi = HttpApi.make("permission")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { Project } from "@/project/project"
-import { InstanceBootstrap } from "@/project/bootstrap"
 import { ProjectID } from "@/project/schema"
 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 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")
 export const ProjectApi = HttpApi.make("project")
   .add(
   .add(
     HttpApiGroup.make("project")
     HttpApiGroup.make("project")
       .add(
       .add(
         HttpApiEndpoint.get("list", root, {
         HttpApiEndpoint.get("list", root, {
-          success: Schema.Array(Project.Info),
+          success: described(Schema.Array(Project.Info), "List of projects"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "project.list",
             identifier: "project.list",
@@ -24,7 +27,7 @@ export const ProjectApi = HttpApi.make("project")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.get("current", `${root}/current`, {
         HttpApiEndpoint.get("current", `${root}/current`, {
-          success: Project.Info,
+          success: described(Project.Info, "Current project information"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "project.current",
             identifier: "project.current",
@@ -33,7 +36,7 @@ export const ProjectApi = HttpApi.make("project")
           }),
           }),
         ),
         ),
         HttpApiEndpoint.post("initGit", `${root}/git/init`, {
         HttpApiEndpoint.post("initGit", `${root}/git/init`, {
-          success: Project.Info,
+          success: described(Project.Info, "Project information after git initialization"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "project.initGit",
             identifier: "project.initGit",
@@ -43,8 +46,9 @@ export const ProjectApi = HttpApi.make("project")
         ),
         ),
         HttpApiEndpoint.patch("update", `${root}/:projectID`, {
         HttpApiEndpoint.patch("update", `${root}/:projectID`, {
           params: { projectID: 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(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "project.update",
             identifier: "project.update",
@@ -59,6 +63,7 @@ export const ProjectApi = HttpApi.make("project")
           description: "Experimental HttpApi project routes.",
           description: "Experimental HttpApi project routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -68,40 +73,3 @@ export const ProjectApi = HttpApi.make("project")
       description: "Experimental HttpApi surface for selected instance routes.",
       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 { Question } from "@/question"
 import { QuestionID } from "@/question/schema"
 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 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")
 export const QuestionApi = HttpApi.make("question")
   .add(
   .add(
     HttpApiGroup.make("question")
     HttpApiGroup.make("question")
       .add(
       .add(
         HttpApiEndpoint.get("list", root, {
         HttpApiEndpoint.get("list", root, {
-          success: Schema.Array(Question.Request),
+          success: described(Schema.Array(Question.Request), "List of pending questions"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "question.list",
             identifier: "question.list",
@@ -21,8 +28,9 @@ export const QuestionApi = HttpApi.make("question")
         ),
         ),
         HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
         HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
           params: { requestID: QuestionID },
           params: { requestID: QuestionID },
-          payload: Question.Reply,
-          success: Schema.Boolean,
+          payload: ReplyPayload,
+          success: described(Schema.Boolean, "Question answered successfully"),
+          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "question.reply",
             identifier: "question.reply",
@@ -32,7 +40,8 @@ export const QuestionApi = HttpApi.make("question")
         ),
         ),
         HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
         HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
           params: { requestID: QuestionID },
           params: { requestID: QuestionID },
-          success: Schema.Boolean,
+          success: described(Schema.Boolean, "Question rejected successfully"),
+          error: [HttpApiError.BadRequest, HttpApiError.NotFound],
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "question.reject",
             identifier: "question.reject",
@@ -47,6 +56,7 @@ export const QuestionApi = HttpApi.make("question")
           description: "Question routes.",
           description: "Question routes.",
         }),
         }),
       )
       )
+      .middleware(InstanceContextMiddleware)
       .middleware(Authorization),
       .middleware(Authorization),
   )
   )
   .annotateMerge(
   .annotateMerge(
@@ -56,31 +66,3 @@ export const QuestionApi = HttpApi.make("question")
       description: "Effect HttpApi surface for instance routes.",
       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 { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { PermissionID } from "@/permission/schema"
 import { Instance } from "@/project/instance"
 import { Instance } from "@/project/instance"
-import { ModelID, ProviderID } from "@/provider/schema"
 import { SessionShare } from "@/share/session"
 import { SessionShare } from "@/share/session"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import { SessionCompaction } from "@/session/compaction"
 import { SessionCompaction } from "@/session/compaction"
@@ -18,422 +17,27 @@ import { SessionStatus } from "@/session/status"
 import { SessionSummary } from "@/session/summary"
 import { SessionSummary } from "@/session/summary"
 import { Todo } from "@/session/todo"
 import { Todo } from "@/session/todo"
 import { MessageID, PartID, SessionID } from "@/session/schema"
 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 * as Log from "@opencode-ai/core/util/log"
 import { NamedError } from "@opencode-ai/core/util/error"
 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 * as Stream from "effect/Stream"
 import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 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 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* () {
   Effect.gen(function* () {
     const session = yield* Session.Service
     const session = yield* Session.Service
     const statusSvc = yield* SessionStatus.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 } }) {
     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 } }) {
     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 }
       params: { sessionID: SessionID }
       query: typeof MessagesQuery.Type
       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: {
     const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
       params: { sessionID: SessionID; messageID: MessageID }
       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"
 import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
 
 
 const disposeAfterResponse = new WeakMap<object, InstanceContext>()
 const disposeAfterResponse = new WeakMap<object, InstanceContext>()
-const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
 
 
 export const markInstanceForDisposal = (ctx: InstanceContext) =>
 export const markInstanceForDisposal = (ctx: InstanceContext) =>
   HttpEffect.appendPreResponseHandler((request, response) =>
   HttpEffect.appendPreResponseHandler((request, response) =>
@@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
   )
   )
 
 
 export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
 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) =>
 export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const response = yield* effect
     const response = yield* effect
     const request = yield* HttpServerRequest.HttpServerRequest
     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)
     const ctx = disposeAfterResponse.get(request.source)
     if (!ctx) return response
     if (!ctx) return response
     disposeAfterResponse.delete(request.source)
     disposeAfterResponse.delete(request.source)
-    yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
+    yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
     return response
     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 = {
 type OpenApiParameter = {
   name: string
   name: string
@@ -26,11 +10,12 @@ type OpenApiParameter = {
 
 
 type OpenApiOperation = {
 type OpenApiOperation = {
   parameters?: OpenApiParameter[]
   parameters?: OpenApiParameter[]
-  responses?: Record<string, unknown>
+  responses?: Record<string, OpenApiResponse>
   requestBody?: {
   requestBody?: {
     required?: boolean
     required?: boolean
     content?: Record<string, { schema?: OpenApiSchema }>
     content?: Record<string, { schema?: OpenApiSchema }>
   }
   }
+  security?: unknown
 }
 }
 
 
 type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
 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 = {
 type OpenApiSpec = {
   components?: {
   components?: {
     schemas?: Record<string, OpenApiSchema>
     schemas?: Record<string, OpenApiSchema>
+    securitySchemes?: Record<string, unknown>
   }
   }
   paths?: Record<string, OpenApiPathItem>
   paths?: Record<string, OpenApiPathItem>
 }
 }
@@ -47,16 +33,25 @@ type OpenApiSchema = {
   additionalProperties?: OpenApiSchema | boolean
   additionalProperties?: OpenApiSchema | boolean
   allOf?: OpenApiSchema[]
   allOf?: OpenApiSchema[]
   anyOf?: OpenApiSchema[]
   anyOf?: OpenApiSchema[]
-  enum?: string[]
+  description?: string
+  enum?: Array<string | boolean>
   items?: OpenApiSchema
   items?: OpenApiSchema
   maximum?: number
   maximum?: number
   minimum?: number
   minimum?: number
   oneOf?: OpenApiSchema[]
   oneOf?: OpenApiSchema[]
   prefixItems?: OpenApiSchema[]
   prefixItems?: OpenApiSchema[]
   properties?: Record<string, OpenApiSchema>
   properties?: Record<string, OpenApiSchema>
+  required?: string[]
   type?: 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 = [
 const InstanceQueryParameters = [
   {
   {
     name: "directory",
     name: "directory",
@@ -72,8 +67,9 @@ const InstanceQueryParameters = [
   },
   },
 ] satisfies OpenApiParameter[]
 ] 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 QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
 const QueryBooleanParameters = new Set(["roots", "archived"])
 const QueryBooleanParameters = new Set(["roots", "archived"])
 const QueryParameterSchemas = {
 const QueryParameterSchemas = {
@@ -81,60 +77,80 @@ const QueryParameterSchemas = {
   "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
   "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
 } satisfies Record<string, OpenApiSchema>
 } 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>) {
 function matchLegacyOpenApi(input: Record<string, unknown>) {
   const spec = input as OpenApiSpec
   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 ?? {})) {
   for (const [path, item] of Object.entries(spec.paths ?? {})) {
     const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
     const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
     for (const method of ["get", "post", "put", "delete", "patch"] as const) {
     for (const method of ["get", "post", "put", "delete", "patch"] as const) {
       const operation = item[method]
       const operation = item[method]
       if (!operation) continue
       if (!operation) continue
       if (operation.requestBody) {
       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
         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") {
         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?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
           if (properties?.extra) properties.extra = { anyOf: [properties.extra, { 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") {
       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"] = {
         operation.responses!["200"] = {
           description: "Event stream",
           description: "Event stream",
           content: {
           content: {
             "text/event-stream": {
             "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
   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)
   const options = flattenOptions(schema.anyOf ?? schema.oneOf)
   if (options) {
   if (options) {
     const withoutNull = options.filter((item) => item.type !== "null")
     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.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.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) {
   if (schema.properties) {
     for (const [key, value] of Object.entries(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") {
   if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
-    schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties)
+    schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
   }
   }
   return schema
   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) {
 function normalizeParameter(param: OpenApiParameter, route: string) {
@@ -205,28 +483,10 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
     }
     }
     return
     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(
   .annotateMerge(
     OpenApi.annotations({
     OpenApi.annotations({
       title: "opencode",
       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 { 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 { Account } from "@/account/account"
 import { Agent } from "@/agent/agent"
 import { Agent } from "@/agent/agent"
 import { Auth } from "@/auth"
 import { Auth } from "@/auth"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { Command } from "@/command"
 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 * as Observability from "@opencode-ai/core/effect/observability"
 import { File } from "@/file"
 import { File } from "@/file"
 import { Ripgrep } from "@/file/ripgrep"
 import { Ripgrep } from "@/file/ripgrep"
@@ -16,8 +14,6 @@ import { Format } from "@/format"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
 import { MCP } from "@/mcp"
 import { MCP } from "@/mcp"
 import { Permission } from "@/permission"
 import { Permission } from "@/permission"
-import { InstanceBootstrap } from "@/project/bootstrap"
-import { Instance } from "@/project/instance"
 import { Installation } from "@/installation"
 import { Installation } from "@/installation"
 import { Project } from "@/project/project"
 import { Project } from "@/project/project"
 import { ProviderAuth } from "@/provider/auth"
 import { ProviderAuth } from "@/provider/auth"
@@ -32,131 +28,103 @@ import { Todo } from "@/session/todo"
 import { Skill } from "@/skill"
 import { Skill } from "@/skill"
 import { ToolRegistry } from "@/tool/registry"
 import { ToolRegistry } from "@/tool/registry"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
-import { Filesystem } from "@/util/filesystem"
 import { Vcs } from "@/project/vcs"
 import { Vcs } from "@/project/vcs"
 import { Worktree } from "@/worktree"
 import { Worktree } from "@/worktree"
+import { InstanceHttpApi, RootHttpApi } from "./api"
 import { authorizationLayer } from "./auth"
 import { authorizationLayer } from "./auth"
-import { ConfigApi, configHandlers } from "./config"
-import { ControlApi, controlHandlers } from "./control"
 import { eventRoute } from "./event"
 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 { disposeMiddleware } from "./lifecycle"
 import { memoMap } from "@opencode-ai/core/effect/memo-map"
 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>
 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
 ).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(() =>
 export const webHandler = lazy(() =>
   HttpRouter.toWebHandler(routes, {
   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 { InstanceMiddleware } from "./routes/instance/middleware"
 import { WorkspaceRoutes } from "./routes/control/workspace"
 import { WorkspaceRoutes } from "./routes/control/workspace"
 import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
 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
 // @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
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -37,13 +38,38 @@ type ServerApp = {
   request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
   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[] }) {
 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() {
 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()
   const app = new Hono()
     .onError(ErrorMiddleware)
     .onError(ErrorMiddleware)
     .use(AuthMiddleware)
     .use(AuthMiddleware)
-    .use(LoggerMiddleware)
+    .use(LoggerMiddleware(backendAttributes))
     .use(CompressionMiddleware)
     .use(CompressionMiddleware)
     .use(CorsMiddleware(opts))
     .use(CorsMiddleware(opts))
     .route("/global", GlobalRoutes())
     .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" },
   { method: "GET", path: "/session", action: "local" },
 ]
 ]
 
 
-function local(method: string, path: string) {
+export function isLocalWorkspaceRoute(method: string, path: string) {
   for (const rule of RULES) {
   for (const rule of RULES) {
     if (rule.method && rule.method !== method) continue
     if (rule.method && rule.method !== method) continue
     const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
     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
   return false
 }
 }
 
 
-function getSessionID(url: URL) {
+export function getWorkspaceRouteSessionID(url: URL) {
   if (url.pathname === "/session/status") return null
   if (url.pathname === "/session/status") return null
 
 
   const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
   const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
@@ -39,8 +39,17 @@ function getSessionID(url: URL) {
   return SessionID.make(id)
   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) {
 async function getSessionWorkspace(url: URL) {
-  const id = getSessionID(url)
+  const id = getWorkspaceRouteSessionID(url)
   if (!id) return null
   if (!id) return null
 
 
   const session = await AppRuntime.runPromise(
   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
       // No instance provided because we are serving cached data; there
       // is no instance to work with
       // is no instance to work with
       return next()
       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", {
     log.info("workspace proxy forwarding", {
       workspaceID,
       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 AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
 export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
 export const StructuredOutputError = namedSchemaError("StructuredOutputError", {
   message: Schema.String,
   message: Schema.String,
-  retries: Schema.Number,
+  retries: NonNegativeInt,
 })
 })
 export const AuthError = namedSchemaError("ProviderAuthError", {
 export const AuthError = namedSchemaError("ProviderAuthError", {
   providerID: Schema.String,
   providerID: Schema.String,
@@ -50,7 +50,7 @@ export const AuthError = namedSchemaError("ProviderAuthError", {
 })
 })
 export const APIError = namedSchemaError("APIError", {
 export const APIError = namedSchemaError("APIError", {
   message: Schema.String,
   message: Schema.String,
-  statusCode: Schema.optional(Schema.Number),
+  statusCode: Schema.optional(NonNegativeInt),
   isRetryable: Schema.Boolean,
   isRetryable: Schema.Boolean,
   responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
   responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
   responseBody: Schema.optional(Schema.String),
   responseBody: Schema.optional(Schema.String),
@@ -116,8 +116,8 @@ export const TextPart = Schema.Struct({
   ignored: Schema.optional(Schema.Boolean),
   ignored: Schema.optional(Schema.Boolean),
   time: Schema.optional(
   time: Schema.optional(
     Schema.Struct({
     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)),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
@@ -132,8 +132,8 @@ export const ReasoningPart = Schema.Struct({
   text: Schema.String,
   text: Schema.String,
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   time: Schema.Struct({
   time: Schema.Struct({
-    start: Schema.Number,
-    end: Schema.optional(Schema.Number),
+    start: NonNegativeInt,
+    end: Schema.optional(NonNegativeInt),
   }),
   }),
 })
 })
   .annotate({ identifier: "ReasoningPart" })
   .annotate({ identifier: "ReasoningPart" })
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
 const filePartSourceBase = {
 const filePartSourceBase = {
   text: Schema.Struct({
   text: Schema.Struct({
     value: Schema.String,
     value: Schema.String,
-    start: Schema.Int,
-    end: Schema.Int,
+    start: NonNegativeInt,
+    end: NonNegativeInt,
   }).annotate({ identifier: "FilePartSourceText" }),
   }).annotate({ identifier: "FilePartSourceText" }),
 }
 }
 
 
@@ -162,7 +162,7 @@ export const SymbolSource = Schema.Struct({
   path: Schema.String,
   path: Schema.String,
   range: LSP.Range,
   range: LSP.Range,
   name: Schema.String,
   name: Schema.String,
-  kind: Schema.Int,
+  kind: NonNegativeInt,
 })
 })
   .annotate({ identifier: "SymbolSource" })
   .annotate({ identifier: "SymbolSource" })
   .pipe(withStatics((s) => ({ zod: zod(s) })))
   .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -201,8 +201,8 @@ export const AgentPart = Schema.Struct({
   source: Schema.optional(
   source: Schema.optional(
     Schema.Struct({
     Schema.Struct({
       value: Schema.String,
       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({
 export const RetryPart = Schema.Struct({
   ...partBase,
   ...partBase,
   type: Schema.Literal("retry"),
   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({
   time: Schema.Struct({
-    created: Schema.Number,
+    created: NonNegativeInt,
   }),
   }),
 })
 })
   .annotate({ identifier: "RetryPart" })
   .annotate({ identifier: "RetryPart" })
@@ -269,15 +268,15 @@ export const StepFinishPart = Schema.Struct({
   type: Schema.Literal("step-finish"),
   type: Schema.Literal("step-finish"),
   reason: Schema.String,
   reason: Schema.String,
   snapshot: Schema.optional(Schema.String),
   snapshot: Schema.optional(Schema.String),
-  cost: Schema.Number,
+  cost: Schema.Finite,
   tokens: Schema.Struct({
   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({
     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),
   title: Schema.optional(Schema.String),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   time: Schema.Struct({
   time: Schema.Struct({
-    start: Schema.Number,
+    start: NonNegativeInt,
   }),
   }),
 })
 })
   .annotate({ identifier: "ToolStateRunning" })
   .annotate({ identifier: "ToolStateRunning" })
@@ -314,9 +313,9 @@ export const ToolStateCompleted = Schema.Struct({
   title: Schema.String,
   title: Schema.String,
   metadata: Schema.Record(Schema.String, Schema.Any),
   metadata: Schema.Record(Schema.String, Schema.Any),
   time: Schema.Struct({
   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)),
   attachments: Schema.optional(Schema.Array(FilePart)),
 })
 })
@@ -336,8 +335,8 @@ export const ToolStateError = Schema.Struct({
   error: Schema.String,
   error: Schema.String,
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
   time: Schema.Struct({
   time: Schema.Struct({
-    start: Schema.Number,
-    end: Schema.Number,
+    start: NonNegativeInt,
+    end: NonNegativeInt,
   }),
   }),
 })
 })
   .annotate({ identifier: "ToolStateError" })
   .annotate({ identifier: "ToolStateError" })
@@ -380,7 +379,7 @@ export const User = Schema.Struct({
   ...messageBase,
   ...messageBase,
   role: Schema.Literal("user"),
   role: Schema.Literal("user"),
   time: Schema.Struct({
   time: Schema.Struct({
-    created: Schema.Number,
+    created: NonNegativeInt,
   }),
   }),
   format: Schema.optional(_Format),
   format: Schema.optional(_Format),
   summary: Schema.optional(
   summary: Schema.optional(
@@ -447,9 +446,7 @@ export type Part =
   | RetryPart
   | RetryPart
   | CompactionPart
   | 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", [
 const AssistantErrorZod = z.discriminatedUnion("name", [
   AuthError.Schema,
   AuthError.Schema,
   NamedError.Unknown.Schema,
   NamedError.Unknown.Schema,
@@ -461,6 +458,17 @@ const AssistantErrorZod = z.discriminatedUnion("name", [
 ])
 ])
 type AssistantError = z.infer<typeof AssistantErrorZod>
 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 ─────────────────────────────────────────────────────
 // ── Prompt input schemas ─────────────────────────────────────────────────────
 //
 //
 // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
 // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
@@ -477,8 +485,8 @@ export const TextPartInput = Schema.Struct({
   ignored: Schema.optional(Schema.Boolean),
   ignored: Schema.optional(Schema.Boolean),
   time: Schema.optional(
   time: Schema.optional(
     Schema.Struct({
     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)),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
@@ -506,8 +514,8 @@ export const AgentPartInput = Schema.Struct({
   source: Schema.optional(
   source: Schema.optional(
     Schema.Struct({
     Schema.Struct({
       value: Schema.String,
       value: Schema.String,
-      start: Schema.Int,
-      end: Schema.Int,
+      start: NonNegativeInt,
+      end: NonNegativeInt,
     }),
     }),
   ),
   ),
 })
 })
@@ -537,10 +545,10 @@ export const Assistant = Schema.Struct({
   ...messageBase,
   ...messageBase,
   role: Schema.Literal("assistant"),
   role: Schema.Literal("assistant"),
   time: Schema.Struct({
   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,
   parentID: MessageID,
   modelID: ModelID,
   modelID: ModelID,
   providerID: ProviderID,
   providerID: ProviderID,
@@ -554,15 +562,15 @@ export const Assistant = Schema.Struct({
     root: Schema.String,
     root: Schema.String,
   }),
   }),
   summary: Schema.optional(Schema.Boolean),
   summary: Schema.optional(Schema.Boolean),
-  cost: Schema.Number,
+  cost: Schema.Finite,
   tokens: Schema.Struct({
   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({
     cache: Schema.Struct({
-      read: Schema.Number,
-      write: Schema.Number,
+      read: NonNegativeInt,
+      write: NonNegativeInt,
     }),
     }),
   }),
   }),
   structured: Schema.optional(Schema.Any),
   structured: Schema.optional(Schema.Any),
@@ -594,7 +602,7 @@ const RemovedEventSchema = Schema.Struct({
 const PartUpdatedEventSchema = Schema.Struct({
 const PartUpdatedEventSchema = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
   part: _Part,
   part: _Part,
-  time: Schema.Number,
+  time: NonNegativeInt,
 })
 })
 
 
 const PartRemovedEventSchema = Schema.Struct({
 const PartRemovedEventSchema = Schema.Struct({
@@ -651,7 +659,7 @@ export type WithParts = {
 
 
 const Cursor = Schema.Struct({
 const Cursor = Schema.Struct({
   id: MessageID,
   id: MessageID,
-  time: Schema.Number,
+  time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
 })
 })
 type Cursor = typeof Cursor.Type
 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 { SessionID } from "./schema"
 import { ModelID, ProviderID } from "../provider/schema"
 import { ModelID, ProviderID } from "../provider/schema"
 import { zod } from "@/util/effect-zod"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, withStatics } from "@/util/schema"
 import { namedSchemaError } from "@/util/named-schema-error"
 import { namedSchemaError } from "@/util/named-schema-error"
 
 
 export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
 export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
@@ -33,7 +33,7 @@ const UnknownErrorEffect = Schema.Struct({
 
 
 export const ToolCall = Schema.Struct({
 export const ToolCall = Schema.Struct({
   state: Schema.Literal("call"),
   state: Schema.Literal("call"),
-  step: Schema.optional(Schema.Number),
+  step: Schema.optional(NonNegativeInt),
   toolCallId: Schema.String,
   toolCallId: Schema.String,
   toolName: Schema.String,
   toolName: Schema.String,
   args: Schema.Unknown,
   args: Schema.Unknown,
@@ -44,7 +44,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
 
 
 export const ToolPartialCall = Schema.Struct({
 export const ToolPartialCall = Schema.Struct({
   state: Schema.Literal("partial-call"),
   state: Schema.Literal("partial-call"),
-  step: Schema.optional(Schema.Number),
+  step: Schema.optional(NonNegativeInt),
   toolCallId: Schema.String,
   toolCallId: Schema.String,
   toolName: Schema.String,
   toolName: Schema.String,
   args: Schema.Unknown,
   args: Schema.Unknown,
@@ -55,7 +55,7 @@ export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
 
 
 export const ToolResult = Schema.Struct({
 export const ToolResult = Schema.Struct({
   state: Schema.Literal("result"),
   state: Schema.Literal("result"),
-  step: Schema.optional(Schema.Number),
+  step: Schema.optional(NonNegativeInt),
   toolCallId: Schema.String,
   toolCallId: Schema.String,
   toolName: Schema.String,
   toolName: Schema.String,
   args: Schema.Unknown,
   args: Schema.Unknown,
@@ -141,8 +141,8 @@ export const Info = Schema.Struct({
   parts: Schema.Array(MessagePart),
   parts: Schema.Array(MessagePart),
   metadata: Schema.Struct({
   metadata: Schema.Struct({
     time: 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])),
     error: Schema.optional(Schema.Union([AuthErrorEffect, UnknownErrorEffect, OutputLengthErrorEffect])),
     sessionID: SessionID,
     sessionID: SessionID,
@@ -153,8 +153,8 @@ export const Info = Schema.Struct({
           title: Schema.String,
           title: Schema.String,
           snapshot: Schema.optional(Schema.String),
           snapshot: Schema.optional(Schema.String),
           time: Schema.Struct({
           time: Schema.Struct({
-            start: Schema.Number,
-            end: Schema.Number,
+            start: NonNegativeInt,
+            end: NonNegativeInt,
           }),
           }),
         }),
         }),
         [Schema.Record(Schema.String, Schema.Unknown)],
         [Schema.Record(Schema.String, Schema.Unknown)],
@@ -169,15 +169,15 @@ export const Info = Schema.Struct({
           cwd: Schema.String,
           cwd: Schema.String,
           root: Schema.String,
           root: Schema.String,
         }),
         }),
-        cost: Schema.Number,
+        cost: Schema.Finite,
         summary: Schema.optional(Schema.Boolean),
         summary: Schema.optional(Schema.Boolean),
         tokens: Schema.Struct({
         tokens: Schema.Struct({
-          input: Schema.Number,
-          output: Schema.Number,
-          reasoning: Schema.Number,
+          input: NonNegativeInt,
+          output: NonNegativeInt,
+          reasoning: NonNegativeInt,
           cache: Schema.Struct({
           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 { Global } from "@opencode-ai/core/global"
 import { Effect, Layer, Option, Context, Schema, Types } from "effect"
 import { Effect, Layer, Option, Context, Schema, Types } from "effect"
 import { zod } from "@/util/effect-zod"
 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" })
 const log = Log.create({ service: "session" })
 
 
@@ -132,9 +132,9 @@ function sessionPath(worktree: string, cwd: string) {
 }
 }
 
 
 const Summary = Schema.Struct({
 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)),
   diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
 })
 })
 
 
@@ -143,10 +143,10 @@ const Share = Schema.Struct({
 })
 })
 
 
 const Time = 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({
 const Revert = Schema.Struct({
@@ -215,7 +215,7 @@ export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema
 )
 )
 export const SetArchivedInput = Schema.Struct({
 export const SetArchivedInput = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
-  time: Schema.optional(Schema.Number),
+  time: Schema.optional(NonNegativeInt),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export const SetPermissionInput = Schema.Struct({
 export const SetPermissionInput = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
@@ -228,7 +228,7 @@ export const SetRevertInput = Schema.Struct({
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export const MessagesInput = Schema.Struct({
 export const MessagesInput = Schema.Struct({
   sessionID: SessionID,
   sessionID: SessionID,
-  limit: Schema.optional(Schema.Number),
+  limit: Schema.optional(NonNegativeInt),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
 const CreatedEventSchema = Schema.Struct({
 const CreatedEventSchema = Schema.Struct({
@@ -241,10 +241,10 @@ const UpdatedShare = Schema.Struct({
 })
 })
 
 
 const UpdatedTime = 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({
 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 { InstanceState } from "@/effect/instance-state"
 import { SessionID } from "./schema"
 import { SessionID } from "./schema"
 import { zod } from "@/util/effect-zod"
 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 { Effect, Layer, Context, Schema } from "effect"
 import z from "zod"
 import z from "zod"
 
 
@@ -13,9 +13,9 @@ export const Info = Schema.Union([
   }),
   }),
   Schema.Struct({
   Schema.Struct({
     type: Schema.Literal("retry"),
     type: Schema.Literal("retry"),
-    attempt: Schema.Number,
+    attempt: NonNegativeInt,
     message: Schema.String,
     message: Schema.String,
-    next: Schema.Number,
+    next: NonNegativeInt,
   }),
   }),
   Schema.Struct({
   Schema.Struct({
     type: Schema.Literal("busy"),
     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 { Config } from "@/config/config"
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
 import * as Log from "@opencode-ai/core/util/log"
 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"
 import { zod } from "@/util/effect-zod"
 
 
 export const Patch = Schema.Struct({
 export const Patch = Schema.Struct({
@@ -22,8 +22,8 @@ export type Patch = typeof Patch.Type
 export const FileDiff = Schema.Struct({
 export const FileDiff = Schema.Struct({
   file: Schema.String,
   file: Schema.String,
   patch: Schema.String,
   patch: Schema.String,
-  additions: Schema.Number,
-  deletions: Schema.Number,
+  additions: NonNegativeInt,
+  deletions: NonNegativeInt,
   status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
   status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
 })
 })
   .annotate({ identifier: "SnapshotFileDiff" })
   .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 z from "zod"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
 import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
+import { NonNegativeInt } from "@/util/schema"
 import { Git } from "@/git"
 import { Git } from "@/git"
 
 
 const log = Log.create({ service: "storage" })
 const log = Log.create({ service: "storage" })
@@ -41,8 +42,8 @@ const MessageFile = Schema.Struct({
 })
 })
 
 
 const DiffFile = Schema.Struct({
 const DiffFile = Schema.Struct({
-  additions: Schema.Number,
-  deletions: Schema.Number,
+  additions: NonNegativeInt,
+  deletions: NonNegativeInt,
 })
 })
 
 
 const SummaryFile = Schema.Struct({
 const SummaryFile = Schema.Struct({

+ 16 - 0
packages/opencode/src/sync/index.ts

@@ -294,4 +294,20 @@ export function payloads() {
     .toArray()
     .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 "."
 export * as SyncEvent from "."

+ 2 - 1
packages/opencode/src/tool/bash.ts

@@ -1,4 +1,5 @@
 import { Schema } from "effect"
 import { Schema } from "effect"
+import { PositiveInt } from "@/util/schema"
 import os from "os"
 import os from "os"
 import { createWriteStream } from "node:fs"
 import { createWriteStream } from "node:fs"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
@@ -53,7 +54,7 @@ const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurs
 
 
 export const Parameters = Schema.Struct({
 export const Parameters = Schema.Struct({
   command: Schema.String.annotate({ description: "The command to execute" }),
   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({
   workdir: Schema.optional(Schema.String).annotate({
     description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
     description: `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:
     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'",
       "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))
     .check(Schema.isLessThanOrEqualTo(50000))
     .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
     .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000)))
     .annotate({
     .annotate({

+ 6 - 6
packages/opencode/src/tool/lsp.ts

@@ -23,12 +23,12 @@ const operations = [
 export const Parameters = Schema.Struct({
 export const Parameters = Schema.Struct({
   operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
   operation: Schema.Literals(operations).annotate({ description: "The LSP operation to perform" }),
   filePath: Schema.String.annotate({ description: "The absolute or relative path to the file" }),
   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({
   query: Schema.optional(Schema.String).annotate({
     description: "Search query for workspaceSymbol. Empty string requests all symbols.",
     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 { Effect, Option, Schema, Scope } from "effect"
+import { NonNegativeInt } from "@/util/schema"
 import { createReadStream } from "fs"
 import { createReadStream } from "fs"
 import * as path from "path"
 import * as path from "path"
 import { createInterface } from "readline"
 import { createInterface } from "readline"
@@ -25,10 +26,10 @@ const SAMPLE_BYTES = 4096
 // unchanged; purely CLI-facing uses must now send numbers rather than strings.
 // unchanged; purely CLI-facing uses must now send numbers rather than strings.
 export const Parameters = Schema.Struct({
 export const Parameters = Schema.Struct({
   filePath: Schema.String.annotate({ description: "The absolute path to the file or directory to read" }),
   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)",
     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)",
     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 })
     .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>
   type Data = Schema.Schema.Type<typeof dataSchema>
 
 
   class NamedSchemaError extends Error {
   class NamedSchemaError extends Error {
     static readonly Schema = wire
     static readonly Schema = wire
+    static readonly EffectSchema = effectSchema
     static readonly tag = tag
     static readonly tag = tag
     public static isInstance(input: unknown): input is NamedSchemaError {
     public static isInstance(input: unknown): input is NamedSchemaError {
       return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag
       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))
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
 
 
+
+
 /**
 /**
  * Optional public JSON field that can hold explicit `undefined` on the type
  * Optional public JSON field that can hold explicit `undefined` on the type
  * side but encodes it as an omitted key, matching legacy `JSON.stringify`.
  * 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 { Schema } from "effect"
+import { NonNegativeInt } from "@/util/schema"
 import { SessionEvent } from "./session-event"
 import { SessionEvent } from "./session-event"
 
 
 export const ID = SessionEvent.ID
 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")({
 export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
-  attempt: Schema.Number,
+  attempt: NonNegativeInt,
   error: SessionEvent.RetryError,
   error: SessionEvent.RetryError,
   time: Schema.Struct({
   time: Schema.Struct({
     created: Schema.DateTimeUtc,
     created: Schema.DateTimeUtc,
@@ -132,14 +133,14 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
   type: Schema.Literal("assistant"),
   type: Schema.Literal("assistant"),
   content: AssistantContent.pipe(Schema.Array),
   content: AssistantContent.pipe(Schema.Array),
   retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
   retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
-  cost: Schema.Number.pipe(Schema.optional),
+  cost: Schema.Finite.pipe(Schema.optional),
   tokens: Schema.Struct({
   tokens: Schema.Struct({
-    input: Schema.Number,
-    output: Schema.Number,
-    reasoning: Schema.Number,
+    input: NonNegativeInt,
+    output: NonNegativeInt,
+    reasoning: NonNegativeInt,
     cache: Schema.Struct({
     cache: Schema.Struct({
-      read: Schema.Number,
-      write: Schema.Number,
+      read: NonNegativeInt,
+      write: NonNegativeInt,
     }),
     }),
   }).pipe(Schema.optional),
   }).pipe(Schema.optional),
   error: Schema.String.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 { Identifier } from "@/id/id"
-import { withStatics } from "@/util/schema"
+import { NonNegativeInt, withStatics } from "@/util/schema"
 import * as DateTime from "effect/DateTime"
 import * as DateTime from "effect/DateTime"
 import { Schema } from "effect"
 import { Schema } from "effect"
 
 
@@ -25,8 +25,8 @@ export namespace SessionEvent {
   }
   }
 
 
   export class Source extends Schema.Class<Source>("Session.Event.Source")({
   export class Source extends Schema.Class<Source>("Session.Event.Source")({
-    start: Schema.Number,
-    end: Schema.Number,
+    start: NonNegativeInt,
+    end: NonNegativeInt,
     text: Schema.String,
     text: Schema.String,
   }) {}
   }) {}
 
 
@@ -55,7 +55,7 @@ export namespace SessionEvent {
 
 
   export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
   export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
     message: Schema.String,
     message: Schema.String,
-    statusCode: Schema.Number.pipe(Schema.optional),
+    statusCode: NonNegativeInt.pipe(Schema.optional),
     isRetryable: Schema.Boolean,
     isRetryable: Schema.Boolean,
     responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
     responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
     responseBody: Schema.String.pipe(Schema.optional),
     responseBody: Schema.String.pipe(Schema.optional),
@@ -123,14 +123,14 @@ export namespace SessionEvent {
       ...Base,
       ...Base,
       type: Schema.Literal("step.ended"),
       type: Schema.Literal("step.ended"),
       reason: Schema.String,
       reason: Schema.String,
-      cost: Schema.Number,
+      cost: Schema.Finite,
       tokens: Schema.Struct({
       tokens: Schema.Struct({
-        input: Schema.Number,
-        output: Schema.Number,
-        reasoning: Schema.Number,
+        input: NonNegativeInt,
+        output: NonNegativeInt,
+        reasoning: NonNegativeInt,
         cache: Schema.Struct({
         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")({
   export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
     ...Base,
     ...Base,
     type: Schema.Literal("retried"),
     type: Schema.Literal("retried"),
-    attempt: Schema.Number,
+    attempt: NonNegativeInt,
     error: RetryError,
     error: RetryError,
   }) {
   }) {
     static create(input: BaseInput & { attempt: number; 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 { afterEach, describe, expect, test } from "bun:test"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Instance } from "../../src/project/instance"
 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 { PublicApi } from "../../src/server/routes/instance/httpapi/public"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 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(
   return Object.fromEntries(
     Object.entries(spec.paths).flatMap(([path, item]) =>
     Object.entries(spec.paths).flatMap(([path, item]) =>
       methods
       methods
         .filter((method) => item[method])
         .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 = {
 type Operation = {
   parameters?: unknown[]
   parameters?: unknown[]
   responses?: unknown
   responses?: unknown
@@ -74,7 +90,7 @@ type Operation = {
 }
 }
 
 
 type RequestBody = {
 type RequestBody = {
-  content?: Record<string, { schema?: { $ref?: string; type?: string } }>
+  content?: Record<string, { schema?: OpenApiSchema }>
   required?: boolean
   required?: boolean
 }
 }
 
 
@@ -97,17 +113,27 @@ function parameterSchema(input: {
   return param.schema
   return param.schema
 }
 }
 
 
-function requestBodyKey(body: unknown) {
+function requestBodyKey(spec: OpenApiSpec, body: unknown) {
   if (!body || typeof body !== "object" || !("content" in body)) return ""
   if (!body || typeof body !== "object" || !("content" in body)) return ""
   const requestBody = body as RequestBody
   const requestBody = body as RequestBody
   return JSON.stringify({
   return JSON.stringify({
     required: requestBody.required === true,
     required: requestBody.required === true,
     content: Object.entries(requestBody.content ?? {})
     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(),
       .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: {
 function responseContentTypes(input: {
   spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
   spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
   path: string
   path: string
@@ -146,6 +172,14 @@ afterEach(async () => {
 })
 })
 
 
 describe("HttpApi server", () => {
 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 () => {
   test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
     const honoRoutes = openApiRouteKeys(await Server.openapi())
     const honoRoutes = openApiRouteKeys(await Server.openapi())
     const effectRoutes = openApiRouteKeys(effectOpenApi())
     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 { GlobalBus } from "@/bus/global"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 { Session } from "@/session/session"
 import { Database } from "@/storage/db"
 import { Database } from "@/storage/db"
 import * as Log from "@opencode-ai/core/util/log"
 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 { Context } from "effect"
 import path from "path"
 import path from "path"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 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 { Instance } from "../../src/project/instance"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
 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 { GlobalBus } from "@/bus/global"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 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 { ModelID, ProviderID } from "../../src/provider/schema"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 { MessageID, PartID } from "../../src/session/schema"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
@@ -19,7 +19,7 @@ const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
 
 
 function app(experimental: boolean) {
 function app(experimental: boolean) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
-  return Server.Default().app
+  return experimental ? Server.Default().app : Server.Legacy().app
 }
 }
 type TestApp = ReturnType<typeof 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 { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 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 { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 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) {
 function app(experimental: boolean) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
-  return Server.Default().app
+  return experimental ? Server.Default().app : Server.Legacy().app
 }
 }
 type TestApp = ReturnType<typeof 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) {
 function app(experimental: boolean) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
-  return Server.Default().app
+  return experimental ? Server.Default().app : Server.Legacy().app
 }
 }
 
 
 function requestAuthorize(input: {
 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 { PtyID } from "../../src/pty/schema"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 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 { afterEach, describe, expect, test } from "bun:test"
+import { Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 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 path from "path"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 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(
   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 },
     { preconnect: globalThis.fetch.preconnect },
   ) satisfies typeof globalThis.fetch
   ) satisfies typeof globalThis.fetch
   return createOpencodeClient({
   return createOpencodeClient({
     baseUrl: "http://localhost",
     baseUrl: "http://localhost",
     directory,
     directory,
+    headers: input?.headers,
     fetch,
     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) {
 async function expectStatus(result: Promise<{ response: Response }>, status: number) {
   expect((await result).response.status).toBe(status)
   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 () => {
 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()
   await resetDatabase()
 })
 })
 
 
 describe("HttpApi SDK", () => {
 describe("HttpApi SDK", () => {
   test("uses the generated SDK for global and control routes", async () => {
   test("uses the generated SDK for global and control routes", async () => {
-    const sdk = client()
+    const sdk = client("httpapi")
     const health = await sdk.global.health()
     const health = await sdk.global.health()
 
 
     expect(health.response.status).toBe(200)
     expect(health.response.status).toBe(200)
@@ -60,7 +229,7 @@ describe("HttpApi SDK", () => {
       config: { formatter: false, lsp: false },
       config: { formatter: false, lsp: false },
       init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
       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" })
     const file = await sdk.file.read({ path: "hello.txt" })
     expect(file.response.status).toBe(200)
     expect(file.response.status).toBe(200)
@@ -81,4 +250,381 @@ describe("HttpApi SDK", () => {
       expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
       expectStatus(sdk.find.files({ query: "hello", limit: 10 }), 200),
     ])
     ])
   })
   })
+
+  test("matches generated SDK global and control behavior across backends", async () => {
+    await compareBackends(async (backend) => {
+      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 { ModelID, ProviderID } from "../../src/provider/schema"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 { Session } from "@/session/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { MessageV2 } from "../../src/session/message-v2"
 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 { Flag } from "@opencode-ai/core/flag/flag"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
 import { Server } from "../../src/server/server"
 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 { Session } from "@/session/session"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
@@ -16,7 +16,7 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
 
 
 function app(httpapi = true) {
 function app(httpapi = true) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
   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>) {
 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 { Flag } from "@opencode-ai/core/flag/flag"
 import { SessionID } from "../../src/session/schema"
 import { SessionID } from "../../src/session/schema"
 import { Instance } from "../../src/project/instance"
 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 { callTui } from "../../src/server/routes/instance/tui"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 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 { mkdir } from "node:fs/promises"
 import path from "node:path"
 import path from "node:path"
 import { Effect } from "effect"
 import { Effect } from "effect"
@@ -6,13 +6,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
 import { registerAdaptor } from "../../src/control-plane/adaptors"
 import { registerAdaptor } from "../../src/control-plane/adaptors"
 import type { WorkspaceAdaptor } from "../../src/control-plane/types"
 import type { WorkspaceAdaptor } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 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 { Session } from "@/session/session"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Instance } from "../../src/project/instance"
+import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
 
 
 void Log.init({ print: false })
 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 () => {
 afterEach(async () => {
+  mock.restore()
   Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
   Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
   await Instance.disposeAll()
   await Instance.disposeAll()
@@ -125,4 +160,81 @@ describe("workspace HttpApi", () => {
     expect(listed.status).toBe(200)
     expect(listed.status).toBe(200)
     expect(await listed.json()).toEqual([])
     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": {
     "timeout": {
       "description": "Optional timeout in milliseconds",
       "description": "Optional timeout in milliseconds",
-      "type": "number",
+      "exclusiveMinimum": 0,
+      "maximum": 9007199254740991,
+      "type": "integer",
     },
     },
     "workdir": {
     "workdir": {
       "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
       "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.",
       "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,
       "maximum": 50000,
       "minimum": 1000,
       "minimum": 1000,
-      "type": "number",
+      "type": "integer",
     },
     },
   },
   },
   "required": [
   "required": [
@@ -224,7 +226,6 @@ exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
 }
 }
 `;
 `;
 
 
-
 exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
 exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
 {
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -304,11 +305,15 @@ exports[`tool parameters JSON Schema (wire shape) read 1`] = `
     },
     },
     "limit": {
     "limit": {
       "description": "The maximum number of lines to read (defaults to 2000)",
       "description": "The maximum number of lines to read (defaults to 2000)",
-      "type": "number",
+      "maximum": 9007199254740991,
+      "minimum": 0,
+      "type": "integer",
     },
     },
     "offset": {
     "offset": {
       "description": "The line number to start reading from (1-indexed)",
       "description": "The line number to start reading from (1-indexed)",
-      "type": "number",
+      "maximum": 9007199254740991,
+      "minimum": 0,
+      "type": "integer",
     },
     },
   },
   },
   "required": [
   "required": [