Преглед на файлове

Merge branch 'dev' into tui-favorite-sort-on-query

Kit Langton преди 2 дни
родител
ревизия
2d1ee3a2ac
променени са 72 файла, в които са добавени 2005 реда и са изтрити 1299 реда
  1. 16 16
      bun.lock
  2. 1 1
      packages/app/package.json
  3. 1 1
      packages/console/app/package.json
  4. 1 1
      packages/console/core/package.json
  5. 1 1
      packages/console/function/package.json
  6. 1 1
      packages/console/mail/package.json
  7. 1 1
      packages/desktop-electron/package.json
  8. 1 1
      packages/desktop/package.json
  9. 1 1
      packages/enterprise/package.json
  10. 6 6
      packages/extensions/zed/extension.toml
  11. 1 1
      packages/function/package.json
  12. 1 1
      packages/opencode/package.json
  13. 32 49
      packages/opencode/specs/effect/facades.md
  14. 85 46
      packages/opencode/specs/effect/http-api.md
  15. 10 10
      packages/opencode/specs/effect/instance-context.md
  16. 6 8
      packages/opencode/specs/effect/loose-ends.md
  17. 30 40
      packages/opencode/specs/effect/migration.md
  18. 16 18
      packages/opencode/specs/effect/routes.md
  19. 19 17
      packages/opencode/specs/effect/server-package.md
  20. 3 5
      packages/opencode/specs/effect/tools.md
  21. 1 1
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  22. 6 1
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  23. 4 1
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  24. 1 1
      packages/opencode/src/config/agent.ts
  25. 12 10
      packages/opencode/src/config/command.ts
  26. 17 12
      packages/opencode/src/config/config.ts
  27. 11 10
      packages/opencode/src/config/console-state.ts
  28. 13 9
      packages/opencode/src/config/formatter.ts
  29. 35 27
      packages/opencode/src/config/lsp.ts
  30. 56 62
      packages/opencode/src/config/mcp.ts
  31. 12 1
      packages/opencode/src/config/model-id.ts
  32. 33 30
      packages/opencode/src/config/permission.ts
  33. 9 5
      packages/opencode/src/config/plugin.ts
  34. 110 112
      packages/opencode/src/config/provider.ts
  35. 12 9
      packages/opencode/src/config/skills.ts
  36. 11 5
      packages/opencode/src/npm/index.ts
  37. 0 1
      packages/opencode/src/plugin/github-copilot/copilot.ts
  38. 8 1
      packages/opencode/src/plugin/github-copilot/models.ts
  39. 42 0
      packages/opencode/src/plugin/loader.ts
  40. 0 2
      packages/opencode/src/server/routes/control/index.ts
  41. 7 6
      packages/opencode/src/server/routes/instance/config.ts
  42. 80 92
      packages/opencode/src/server/routes/instance/experimental.ts
  43. 34 54
      packages/opencode/src/server/routes/instance/file.ts
  44. 41 51
      packages/opencode/src/server/routes/instance/index.ts
  45. 48 34
      packages/opencode/src/server/routes/instance/mcp.ts
  46. 18 19
      packages/opencode/src/server/routes/instance/permission.ts
  47. 11 7
      packages/opencode/src/server/routes/instance/project.ts
  48. 53 64
      packages/opencode/src/server/routes/instance/provider.ts
  49. 29 40
      packages/opencode/src/server/routes/instance/pty.ts
  50. 24 23
      packages/opencode/src/server/routes/instance/question.ts
  51. 199 212
      packages/opencode/src/server/routes/instance/session.ts
  52. 37 11
      packages/opencode/src/server/routes/instance/trace.ts
  53. 6 2
      packages/opencode/src/server/routes/instance/tui.ts
  54. 11 8
      packages/opencode/src/server/server.ts
  55. 4 1
      packages/opencode/src/server/workspace.ts
  56. 33 8
      packages/opencode/src/share/share-next.ts
  57. 106 9
      packages/opencode/src/util/effect-zod.ts
  58. 80 79
      packages/opencode/src/v2/session-entry.ts
  59. 1 1
      packages/opencode/src/v2/session-event.ts
  60. 87 0
      packages/opencode/test/config/lsp.test.ts
  61. 76 0
      packages/opencode/test/server/trace-attributes.test.ts
  62. 59 2
      packages/opencode/test/session/session-entry.test.ts
  63. 295 3
      packages/opencode/test/util/effect-zod.test.ts
  64. 1 1
      packages/plugin/package.json
  65. 1 1
      packages/sdk/js/package.json
  66. 7 5
      packages/sdk/js/src/v2/gen/types.gen.ts
  67. 26 36
      packages/sdk/openapi.json
  68. 1 1
      packages/shared/package.json
  69. 1 1
      packages/slack/package.json
  70. 1 1
      packages/ui/package.json
  71. 1 1
      packages/web/package.json
  72. 1 1
      sdks/vscode/package.json

+ 16 - 16
bun.lock

@@ -29,7 +29,7 @@
     },
     },
     "packages/app": {
     "packages/app": {
       "name": "@opencode-ai/app",
       "name": "@opencode-ai/app",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
     },
     },
     "packages/console/app": {
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "name": "@opencode-ai/console-app",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
         "@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
     },
     },
     "packages/console/core": {
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
       "name": "@opencode-ai/console-core",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
         "@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
     },
     },
     "packages/console/function": {
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
       "name": "@opencode-ai/console-function",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/openai": "3.0.48",
         "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
     },
     },
     "packages/console/mail": {
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
       "name": "@opencode-ai/console-mail",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
         "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
     },
     },
     "packages/desktop": {
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
       "name": "@opencode-ai/desktop",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
     },
     },
     "packages/desktop-electron": {
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "effect": "catalog:",
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
         "electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
     },
     },
     "packages/enterprise": {
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
       "name": "@opencode-ai/enterprise",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/shared": "workspace:*",
         "@opencode-ai/shared": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
     },
     },
     "packages/function": {
     "packages/function": {
       "name": "@opencode-ai/function",
       "name": "@opencode-ai/function",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
     },
     },
     "packages/opencode": {
     "packages/opencode": {
       "name": "opencode",
       "name": "opencode",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -458,7 +458,7 @@
     },
     },
     "packages/plugin": {
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
       "name": "@opencode-ai/plugin",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "effect": "catalog:",
         "effect": "catalog:",
@@ -493,7 +493,7 @@
     },
     },
     "packages/sdk/js": {
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "name": "@opencode-ai/sdk",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "cross-spawn": "catalog:",
         "cross-spawn": "catalog:",
       },
       },
@@ -508,7 +508,7 @@
     },
     },
     "packages/shared": {
     "packages/shared": {
       "name": "@opencode-ai/shared",
       "name": "@opencode-ai/shared",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "bin": {
       "bin": {
         "opencode": "./bin/opencode",
         "opencode": "./bin/opencode",
       },
       },
@@ -532,7 +532,7 @@
     },
     },
     "packages/slack": {
     "packages/slack": {
       "name": "@opencode-ai/slack",
       "name": "@opencode-ai/slack",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
         "@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
     },
     },
     "packages/ui": {
     "packages/ui": {
       "name": "@opencode-ai/ui",
       "name": "@opencode-ai/ui",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
     },
     },
     "packages/web": {
     "packages/web": {
       "name": "@opencode-ai/web",
       "name": "@opencode-ai/web",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
         "@astrojs/markdown-remark": "6.3.1",

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/app",
   "name": "@opencode-ai/app",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "exports": {
   "exports": {

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-app",
   "name": "@opencode-ai/console-app",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
   "name": "@opencode-ai/console-core",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-function",
   "name": "@opencode-ai/console-function",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-mail",
   "name": "@opencode-ai/console-mail",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "dependencies": {
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 1
packages/desktop-electron/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "@opencode-ai/desktop-electron",
   "name": "@opencode-ai/desktop-electron",
   "private": true,
   "private": true,
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "homepage": "https://opencode.ai",
   "homepage": "https://opencode.ai",

+ 1 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "@opencode-ai/desktop",
   "name": "@opencode-ai/desktop",
   "private": true,
   "private": true,
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/enterprise",
   "name": "@opencode-ai/enterprise",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 6 - 6
packages/extensions/zed/extension.toml

@@ -1,7 +1,7 @@
 id = "opencode"
 id = "opencode"
 name = "OpenCode"
 name = "OpenCode"
 description = "The open source coding agent."
 description = "The open source coding agent."
-version = "1.4.9"
+version = "1.4.11"
 schema_version = 1
 schema_version = 1
 authors = ["Anomaly"]
 authors = ["Anomaly"]
 repository = "https://github.com/anomalyco/opencode"
 repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
 icon = "./icons/opencode.svg"
 icon = "./icons/opencode.svg"
 
 
 [agent_servers.opencode.targets.darwin-aarch64]
 [agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.darwin-x86_64]
 [agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.linux-aarch64]
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.linux-x86_64]
 [agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
 cmd = "./opencode"
 cmd = "./opencode"
 args = ["acp"]
 args = ["acp"]
 
 
 [agent_servers.opencode.targets.windows-x86_64]
 [agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 cmd = "./opencode.exe"
 args = ["acp"]
 args = ["acp"]

+ 1 - 1
packages/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/function",
   "name": "@opencode-ai/function",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 1
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "name": "opencode",
   "name": "opencode",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 32 - 49
packages/opencode/specs/effect/facades.md

@@ -1,12 +1,13 @@
 # Facade removal checklist
 # Facade removal checklist
 
 
-Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
+Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
 
 
-As of 2026-04-13, latest `origin/dev`:
+Current status on this branch:
 
 
-- `src/` still has 15 `makeRuntime(...)` call sites.
-- 13 of those are still in scope for facade removal.
-- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
+- `src/` has 5 `makeRuntime(...)` call sites total.
+- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
+- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
+- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
 
 
 Recent progress:
 Recent progress:
 
 
@@ -15,8 +16,9 @@ Recent progress:
 
 
 ## Priority hotspots
 ## Priority hotspots
 
 
-- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
-- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
+- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
+- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
+- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
 
 
 ## Completed Batches
 ## Completed Batches
 
 
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
 5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
 5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
 6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
 6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
 
 
-## Next batch
+## Remaining work
 
 
-Recommended next five, in order:
+Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
 
 
-1. `src/permission/index.ts`
-2. `src/agent/agent.ts`
-3. `src/session/summary.ts`
-4. `src/session/revert.ts`
-5. `src/mcp/auth.ts`
-
-Why this batch:
-
-- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
-- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
-- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
-
-After that batch, the expected follow-up is the main session cluster:
-
-1. `src/session/index.ts`
-2. `src/session/prompt.ts`
-3. `src/session/compaction.ts`
+1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
+2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
+3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
 
 
 ## Checklist
 ## Checklist
 
 
-- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
-- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
-- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
-- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
-- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
-- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
-- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
-- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
-- [x] `src/file/index.ts` (`File`) - facades removed and merged.
-- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
-- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
-- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
-- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
-- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
-- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
-- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
-- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
-- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
-- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
-- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
-- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
-- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
-- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
+- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
+- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
+- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
+- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
+- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
+- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
+- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
+- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
+- [x] `src/file/index.ts` (`File`) - facades removed and merged
+- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
+- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
+- [x] `src/config/config.ts` (`Config`) - facades removed and merged
+- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
+- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
+- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
+- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
+- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
+- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
 
 
 ## Excluded `makeRuntime(...)` sites
 ## Excluded `makeRuntime(...)` sites
 
 

+ 85 - 46
packages/opencode/specs/effect/http-api.md

@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
 
 
 ### Mixed handler styles
 ### Mixed handler styles
 
 
-Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
+Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
 
 
 ### Non-JSON routes
 ### Non-JSON routes
 
 
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
 
 
 ### 1. Finish the prerequisites first
 ### 1. Finish the prerequisites first
 
 
-- continue route-handler effectification in `server/instance/*.ts`
+- continue route-handler effectification in `server/routes/instance/*.ts`
 - continue schema migration toward Effect Schema-first DTOs and errors
 - continue schema migration toward Effect Schema-first DTOs and errors
 - keep removing service facades
 - keep removing service facades
 
 
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
 
 
 Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
 Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
 
 
-- `server/instance/question.ts`
-- `server/instance/provider.ts`
-- `server/instance/permission.ts`
+- `server/routes/instance/question.ts`
+- `server/routes/instance/provider.ts`
+- `server/routes/instance/permission.ts`
 
 
 Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
 Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
 
 
@@ -155,9 +155,9 @@ This gives:
 
 
 As each route group is ported to `HttpApi`:
 As each route group is ported to `HttpApi`:
 
 
-1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
-2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
-3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
+1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
+2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
+3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
 4. verify SDK output is unchanged
 4. verify SDK output is unchanged
 
 
 Leave streaming-style endpoints on Hono until there is a clear reason to move them.
 Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
 
 
 SDK shape rule:
 SDK shape rule:
 
 
-- every schema migration must preserve the generated SDK output byte-for-byte
-- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
-- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
-- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
+- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
+- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
+
+### Schema.Class vs Schema.Struct
+
+The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
+
+**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
+
+```ts
+export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
+  static readonly zod = zod(this)
+}
+```
+
+**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
+
+```ts
+export const Info = Schema.Struct({ ... }).pipe(
+  withStatics((s) => ({ zod: zod(s) })),
+)
+export type Info = Schema.Schema.Type<typeof Info>
+```
+
+When to use each:
+
+- Use **Schema.Class** when:
+  - the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
+  - the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
+- Use **Schema.Struct** when:
+  - the type is only used as a nested field inside another named schema
+  - the original Zod was anonymous and promoting it would bloat SDK types with no import value
+
+Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
+
+Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
+
+```ts
+export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
+```
 
 
 Temporary exception:
 Temporary exception:
 
 
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
 3. Apply the schema migration ordering above so those types are Effect Schema-first.
 3. Apply the schema migration ordering above so those types are Effect Schema-first.
 4. Define the `HttpApi` contract separately from the handlers.
 4. Define the `HttpApi` contract separately from the handlers.
 5. Implement handlers by yielding the existing service from context.
 5. Implement handlers by yielding the existing service from context.
-6. Mount the new surface in parallel under an experimental prefix.
+6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
 7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
 7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
 8. Add one end-to-end test and one OpenAPI-focused test.
 8. Add one end-to-end test and one OpenAPI-focused test.
 9. Compare ergonomics before migrating the next endpoint.
 9. Compare ergonomics before migrating the next endpoint.
@@ -250,20 +286,20 @@ Placement rule:
 - keep `HttpApi` code under `src/server`, not `src/effect`
 - keep `HttpApi` code under `src/server`, not `src/effect`
 - `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
 - `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
 - place each `HttpApi` slice next to the HTTP boundary it serves
 - place each `HttpApi` slice next to the HTTP boundary it serves
-- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
-- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
+- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
+- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
 
 
 Suggested file layout for a repeatable spike:
 Suggested file layout for a repeatable spike:
 
 
-- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
-- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
-- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
+- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
+- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
+- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
 
 
 Suggested responsibilities:
 Suggested responsibilities:
 
 
 - `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
 - `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
-- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
-- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
+- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
+- tests should verify the bridged routes through the normal server surface
 
 
 ## Example migration shape
 ## Example migration shape
 
 
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
 - keep handler bodies thin
 - keep handler bodies thin
 - keep transport mapping at the HTTP boundary only
 - keep transport mapping at the HTTP boundary only
 
 
-### 3. Standalone server
+### 3. Bridged server
 
 
-- the Effect HTTP server is self-contained in `httpapi/server.ts`
-- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
-- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
-- each route group exposes its own OpenAPI doc endpoint
+- the Effect HTTP layer is composed in `httpapi/server.ts`
+- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
+- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
+- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
 
 
 ### 4. Verification
 ### 4. Verification
 
 
 - seed real state through the existing service
 - seed real state through the existing service
-- call the experimental endpoints
+- call the bridged endpoints with the flag enabled
 - assert that the service behavior is unchanged
 - assert that the service behavior is unchanged
 - assert that the generated OpenAPI contains the migrated paths and schemas
 - assert that the generated OpenAPI contains the migrated paths and schemas
 
 
 ## Boundary composition
 ## Boundary composition
 
 
-The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
+The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
 
 
 ### Auth
 ### Auth
 
 
-- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
+- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
 - each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
 - each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
-- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
+- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
 
 
 ### Instance and workspace lookup
 ### Instance and workspace lookup
 
 
-- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
+- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
 - this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
 - this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
 - `HttpApi` handlers yield services from context and assume the correct instance has already been provided
 - `HttpApi` handlers yield services from context and assume the correct instance has already been provided
 
 
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
 
 
 The first slice is successful if:
 The first slice is successful if:
 
 
-- the standalone Effect server starts and serves the endpoints independently of the Hono server
+- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
 - the handlers reuse the existing Effect service
 - the handlers reuse the existing Effect service
 - request decoding and response shapes are schema-defined from canonical Effect schemas
 - request decoding and response shapes are schema-defined from canonical Effect schemas
 - any remaining Zod boundary usage is derived from `.zod` or clearly temporary
 - any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -365,17 +401,16 @@ Current instance route inventory:
   endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
   endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
 - `permission` - `bridged`
 - `permission` - `bridged`
   endpoints: `GET /permission`, `POST /permission/:requestID/reply`
   endpoints: `GET /permission`, `POST /permission/:requestID/reply`
-- `provider` - `bridged` (partial)
-  bridged endpoint: `GET /provider/auth`
-  not yet ported: `GET /provider`, OAuth mutations
-- `config` - `next`
-  best next endpoint: `GET /config/providers`
+- `provider` - `bridged`
+  endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
+- `config` - `bridged` (partial)
+  bridged endpoint: `GET /config/providers`
   later endpoint: `GET /config`
   later endpoint: `GET /config`
   defer `PATCH /config` for now
   defer `PATCH /config` for now
-- `project` - `later`
-  best small reads: `GET /project`, `GET /project/current`
+- `project` - `bridged` (partial)
+  bridged endpoints: `GET /project`, `GET /project/current`
   defer git-init mutation first
   defer git-init mutation first
-- `workspace` - `later`
+- `workspace` - `next`
   best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
   best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
   defer create/remove mutations first
   defer create/remove mutations first
 - `file` - `later`
 - `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
 - `tui` - `defer`
 - `tui` - `defer`
   queue-style UI bridge, weak early `HttpApi` fit
   queue-style UI bridge, weak early `HttpApi` fit
 
 
-Recommended near-term sequence after the first spike:
+Recommended near-term sequence:
 
 
-1. `provider` auth read endpoint
-2. `config` providers read endpoint
-3. `project` read endpoints
-4. `workspace` read endpoints
+1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
+2. `config` full read endpoint (`GET /config`)
+3. `file` JSON read endpoints
+4. `mcp` JSON read endpoints
 
 
 ## Checklist
 ## Checklist
 
 
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
 - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
 - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
 - [x] verify OTEL spans and HTTP logs flow to motel
 - [x] verify OTEL spans and HTTP logs flow to motel
 - [x] bridge question, permission, and provider auth routes
 - [x] bridge question, permission, and provider auth routes
-- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
-- [ ] port `config` read endpoints
+- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
+- [x] port `config` providers read endpoint
+- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
+- [ ] port `workspace` read endpoints
+- [ ] port `GET /config` full read endpoint
+- [ ] port `file` JSON read endpoints
 - [ ] decide when to remove the flag and make Effect routes the default
 - [ ] decide when to remove the flag and make Effect routes the default
 
 
 ## Rule of thumb
 ## Rule of thumb

+ 10 - 10
packages/opencode/specs/effect/instance-context.md

@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
 - `Instance.reload(...)`
 - `Instance.reload(...)`
 - `Instance.dispose()` / `Instance.disposeAll()`
 - `Instance.dispose()` / `Instance.disposeAll()`
 
 
-Current total: `54` files in `packages/opencode/src`.
+Current total: `56` files in `packages/opencode/src`.
 
 
 ### Core bridge and plumbing
 ### Core bridge and plumbing
 
 
@@ -177,13 +177,13 @@ Migration rule:
 
 
 These are the current request-entry seams that still create or consume instance context through the legacy helper.
 These are the current request-entry seams that still create or consume instance context through the legacy helper.
 
 
-- `src/server/instance/middleware.ts`
-- `src/server/instance/index.ts`
-- `src/server/instance/project.ts`
-- `src/server/instance/workspace.ts`
-- `src/server/instance/file.ts`
-- `src/server/instance/experimental.ts`
-- `src/server/instance/global.ts`
+- `src/server/routes/instance/middleware.ts`
+- `src/server/routes/instance/index.ts`
+- `src/server/routes/instance/project.ts`
+- `src/server/routes/control/workspace.ts`
+- `src/server/routes/instance/file.ts`
+- `src/server/routes/instance/experimental.ts`
+- `src/server/routes/global.ts`
 
 
 Migration rule:
 Migration rule:
 
 
@@ -239,7 +239,7 @@ Migration rule:
 These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
 These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
 
 
 - `src/agent/agent.ts`
 - `src/agent/agent.ts`
-- `src/config/tui-migrate.ts`
+- `src/cli/cmd/tui/config/tui-migrate.ts`
 - `src/file/index.ts`
 - `src/file/index.ts`
 - `src/file/watcher.ts`
 - `src/file/watcher.ts`
 - `src/format/formatter.ts`
 - `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
 - `src/project/vcs.ts`
 - `src/project/vcs.ts`
 - `src/provider/provider.ts`
 - `src/provider/provider.ts`
 - `src/pty/index.ts`
 - `src/pty/index.ts`
-- `src/session/index.ts`
+- `src/session/session.ts`
 - `src/session/instruction.ts`
 - `src/session/instruction.ts`
 - `src/session/llm.ts`
 - `src/session/llm.ts`
 - `src/session/system.ts`
 - `src/session/system.ts`

+ 6 - 8
packages/opencode/specs/effect/loose-ends.md

@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
 
 
 ## Config / TUI
 ## Config / TUI
 
 
-- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
+- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
       Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
       Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
-- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
+- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
       Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
       Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
-- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
+- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
 
 
 ## ConfigPaths
 ## ConfigPaths
 
 
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
   - `readFile(...)`
   - `readFile(...)`
   - `parseText(...)`
   - `parseText(...)`
 - [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
 - [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
-- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
-- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
+- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
+- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
 
 
 ## Instance cleanup
 ## Instance cleanup
 
 
-- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
-- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
-- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
+- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
 
 
 ## Notes
 ## Notes
 
 

+ 30 - 40
packages/opencode/specs/effect/migration.md

@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
 
 
 ## Service shape
 ## Service shape
 
 
-Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
+Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
 
 
 ```ts
 ```ts
-export namespace Foo {
-  export interface Interface {
-    readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      // For instance-scoped services:
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
-      )
+export interface Interface {
+  readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
+}
 
 
-      const get = Effect.fn("Foo.get")(function* (id: FooID) {
-        const s = yield* InstanceState.get(state)
-        // ...
-      })
+export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
 
 
-      return Service.of({ get })
-    }),
-  )
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
+    )
 
 
-  // Optional: wire dependencies
-  export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+    const get = Effect.fn("Foo.get")(function* (id: FooID) {
+      const s = yield* InstanceState.get(state)
+      // ...
+    })
 
 
-  // Per-service runtime (inside the namespace)
-  const { runPromise } = makeRuntime(Service, defaultLayer)
+    return Service.of({ get })
+  }),
+)
 
 
-  // Async facade functions
-  export async function get(id: FooID) {
-    return runPromise((svc) => svc.get(id))
-  }
-}
+export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+
+export * as Foo from "."
 ```
 ```
 
 
 Rules:
 Rules:
 
 
-- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
-- `runPromise` goes inside the namespace (not exported unless tests need it)
-- Facade functions are plain `async function` — no `fn()` wrappers
-- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
-- No `Layer.fresh` — InstanceState handles per-directory isolation
+- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
+- Use `Effect.fn("Foo.method")` for Effect methods
+- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
+- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
+- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
 
 
 ## Schema → Zod interop
 ## Schema → Zod interop
 
 
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
 
 
 ## Destroying the facades
 ## Destroying the facades
 
 
-This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
+This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
 
 
 These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
 These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
 
 
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
 - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
 - `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
 - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
 - `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
 - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
 - `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
-- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
-- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
+- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
+- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
 - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
 - `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
 - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
 - `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
-- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
+- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
 - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
 - `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
 
 
 ## Route handler effectification
 ## Route handler effectification

+ 16 - 18
packages/opencode/specs/effect/routes.md

@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
 
 
 ## Current route files
 ## Current route files
 
 
-Current instance route files live under `src/server/instance`, not `server/routes`.
+Current instance route files live under `src/server/routes/instance`.
 
 
-The main migration targets are:
+Files that are already mostly on the intended service-yielding shape:
 
 
-- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
-- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
-- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
-- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
-- [ ] `server/instance/pty.ts` — still calls Pty facades directly
-- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
+- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
+- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
+- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
+- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
+- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service`
 
 
-Additional route files that still participate in the migration:
+Files still worth tracking here:
 
 
-- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
-- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
-- [ ] `server/instance/mcp.ts` — MCP facade-heavy
-- [ ] `server/instance/permission.ts` — Permission
-- [ ] `server/instance/workspace.ts` — Workspace
-- [ ] `server/instance/tui.ts` — Bus and Session
-- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
+- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
+- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
+- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
+- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
+- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
+- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
 
 
 ## Notes
 ## Notes
 
 
-- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
-- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
+- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
+- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.

+ 19 - 17
packages/opencode/specs/effect/server-package.md

@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
 Important current facts:
 Important current facts:
 
 
 - there is no `packages/core` or `packages/cli` workspace yet
 - there is no `packages/core` or `packages/cli` workspace yet
-- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
+- there is no `packages/server` workspace yet on this branch
 - the main host server is still Hono-based in `src/server/server.ts`
 - the main host server is still Hono-based in `src/server/server.ts`
 - current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
 - current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
 - the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
 - the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
-- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
-- that experimental slice is mounted under `/experimental/httpapi/question`
-- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
+- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
+- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
+- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
 
 
 This means the package split should start from an extraction path, not from greenfield package ownership.
 This means the package split should start from an extraction path, not from greenfield package ownership.
 
 
@@ -209,17 +209,19 @@ Current host and route composition:
 
 
 - `src/server/server.ts`
 - `src/server/server.ts`
 - `src/server/control/index.ts`
 - `src/server/control/index.ts`
-- `src/server/instance/index.ts`
+- `src/server/routes/instance/index.ts`
 - `src/server/middleware.ts`
 - `src/server/middleware.ts`
 - `src/server/adapter.bun.ts`
 - `src/server/adapter.bun.ts`
 - `src/server/adapter.node.ts`
 - `src/server/adapter.node.ts`
 
 
-Current experimental `HttpApi` slice:
+Current bridged `HttpApi` slices:
 
 
-- `src/server/instance/httpapi/question.ts`
-- `src/server/instance/httpapi/index.ts`
-- `src/server/instance/experimental.ts`
-- `test/server/question-httpapi.test.ts`
+- `src/server/routes/instance/httpapi/question.ts`
+- `src/server/routes/instance/httpapi/permission.ts`
+- `src/server/routes/instance/httpapi/provider.ts`
+- `src/server/routes/instance/httpapi/config.ts`
+- `src/server/routes/instance/httpapi/project.ts`
+- `src/server/routes/instance/httpapi/server.ts`
 
 
 Current OpenAPI flow:
 Current OpenAPI flow:
 
 
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
 
 
 - `src/server/server.ts`
 - `src/server/server.ts`
 - `src/server/control/index.ts`
 - `src/server/control/index.ts`
-- `src/server/instance/*.ts`
+- `src/server/routes/**/*.ts`
 - `src/server/middleware.ts`
 - `src/server/middleware.ts`
 - `src/server/adapter.*.ts`
 - `src/server/adapter.*.ts`
 - `src/effect/app-runtime.ts`
 - `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
 
 
 ## First vertical slice
 ## First vertical slice
 
 
-The first slice for the package split is the existing experimental `question` group.
+The first slice for the package split is still the existing `question` `HttpApi` group.
 
 
 Why `question` first:
 Why `question` first:
 
 
 - it already exists as an experimental `HttpApi` slice
 - it already exists as an experimental `HttpApi` slice
 - it already follows the desired contract and implementation split in one file
 - it already follows the desired contract and implementation split in one file
 - it is already mounted through the current Hono host
 - it is already mounted through the current Hono host
-- it already has an end-to-end test
 - it is JSON-only
 - it is JSON-only
 - it has low blast radius
 - it has low blast radius
 
 
@@ -357,7 +358,7 @@ Done means:
 
 
 Scope:
 Scope:
 
 
-- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
+- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
 - place it in `packages/server/src/definition/question.ts`
 - place it in `packages/server/src/definition/question.ts`
 - aggregate it in `packages/server/src/definition/api.ts`
 - aggregate it in `packages/server/src/definition/api.ts`
 - generate OpenAPI in `packages/server/src/openapi.ts`
 - generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
 
 
 - replace local experimental question route wiring in `packages/opencode`
 - replace local experimental question route wiring in `packages/opencode`
 - keep the same mount path:
 - keep the same mount path:
-- `/experimental/httpapi/question`
-- `/experimental/httpapi/question/doc`
+- `/question`
+- `/question/:requestID/reply`
+- `/question/:requestID/reject`
 
 
 Rules:
 Rules:
 
 
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
 Typical validation for the first waves:
 Typical validation for the first waves:
 
 
 - `bun typecheck` in the touched package directory or directories
 - `bun typecheck` in the touched package directory or directories
-- the relevant route test, especially `test/server/question-httpapi.test.ts`
+- the relevant server / route coverage for the migrated slice
 - merged OpenAPI coverage if the PR touches spec generation
 - merged OpenAPI coverage if the PR touches spec generation
 
 
 Do not run tests from repo root.
 Do not run tests from repo root.

+ 3 - 5
packages/opencode/specs/effect/tools.md

@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
 
 
 ## Exported tools
 ## Exported tools
 
 
-These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
+These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
 
 
 - [x] `apply_patch.ts`
 - [x] `apply_patch.ts`
 - [x] `bash.ts`
 - [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
 - [x] `glob.ts`
 - [x] `glob.ts`
 - [x] `grep.ts`
 - [x] `grep.ts`
 - [x] `invalid.ts`
 - [x] `invalid.ts`
-- [x] `ls.ts`
 - [x] `lsp.ts`
 - [x] `lsp.ts`
 - [x] `multiedit.ts`
 - [x] `multiedit.ts`
 - [x] `plan.ts`
 - [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
 
 
 Notes:
 Notes:
 
 
-- `batch.ts` is no longer a current tool file and should not be tracked here.
+- There is no current `ls.ts` tool file on this branch.
 - `truncate.ts` is an Effect service used by tools, not a tool definition itself.
 - `truncate.ts` is an Effect service used by tools, not a tool definition itself.
 - `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
 - `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
 
 
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
 - [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
 - [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
 - [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
 - [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
 - [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
 - [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
-- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
+- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
 - [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
 - [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
 
 
 Notable items that are already effectively on the target path and do not need separate migration bullets right now:
 Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
 - `write.ts`
 - `write.ts`
 - `codesearch.ts`
 - `codesearch.ts`
 - `websearch.ts`
 - `websearch.ts`
-- `ls.ts`
 - `multiedit.ts`
 - `multiedit.ts`
 - `edit.ts`
 - `edit.ts`
 
 

+ 1 - 1
packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

@@ -31,7 +31,7 @@ export const TuiInfo = z
     $schema: z.string().optional(),
     $schema: z.string().optional(),
     theme: z.string().optional(),
     theme: z.string().optional(),
     keybinds: KeybindOverride.optional(),
     keybinds: KeybindOverride.optional(),
-    plugin: ConfigPlugin.Spec.array().optional(),
+    plugin: ConfigPlugin.Spec.zod.array().optional(),
     plugin_enabled: z.record(z.string(), z.boolean()).optional(),
     plugin_enabled: z.record(z.string(), z.boolean()).optional(),
   })
   })
   .extend(TuiOptions.shape)
   .extend(TuiOptions.shape)

+ 6 - 1
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -158,7 +158,12 @@ export const layer = Layer.effect(
       (dir) =>
       (dir) =>
         npm
         npm
           .install(dir, {
           .install(dir, {
-            add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+            add: [
+              {
+                name: "@opencode-ai/plugin",
+                version: InstallationLocal ? undefined : InstallationVersion,
+              },
+            ],
           })
           })
           .pipe(Effect.forkScoped),
           .pipe(Effect.forkScoped),
       {
       {

+ 4 - 1
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
 import { createMemo, Show } from "solid-js"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
 import { useTheme } from "../../context/theme"
 import { useTuiConfig } from "../../context/tui-config"
 import { useTuiConfig } from "../../context/tui-config"
-import { InstallationVersion } from "@/installation/version"
+import { InstallationChannel, InstallationVersion } from "@/installation/version"
 import { TuiPluginRuntime } from "../../plugin"
 import { TuiPluginRuntime } from "../../plugin"
 
 
 import { getScrollAcceleration } from "../../util/scroll"
 import { getScrollAcceleration } from "../../util/scroll"
@@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 <text fg={theme.text}>
                 <text fg={theme.text}>
                   <b>{session()!.title}</b>
                   <b>{session()!.title}</b>
                 </text>
                 </text>
+                <Show when={InstallationChannel !== "latest"}>
+                  <text fg={theme.textMuted}>{props.sessionID}</text>
+                </Show>
                 <Show when={session()!.workspaceID}>
                 <Show when={session()!.workspaceID}>
                   <text fg={theme.textMuted}>
                   <text fg={theme.textMuted}>
                     <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
                     <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}

+ 1 - 1
packages/opencode/src/config/agent.ts

@@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
 
 
 export const Info = z
 export const Info = z
   .object({
   .object({
-    model: ConfigModelID.optional(),
+    model: ConfigModelID.zod.optional(),
     variant: z
     variant: z
       .string()
       .string()
       .optional()
       .optional()

+ 12 - 10
packages/opencode/src/config/command.ts

@@ -1,10 +1,12 @@
 export * as ConfigCommand from "./command"
 export * as ConfigCommand from "./command"
 
 
 import { Log } from "../util"
 import { Log } from "../util"
-import z from "zod"
+import { Schema } from "effect"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { Glob } from "@opencode-ai/shared/util/glob"
 import { Glob } from "@opencode-ai/shared/util/glob"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import { configEntryNameFromPath } from "./entry-name"
 import { configEntryNameFromPath } from "./entry-name"
 import { InvalidError } from "./error"
 import { InvalidError } from "./error"
 import * as ConfigMarkdown from "./markdown"
 import * as ConfigMarkdown from "./markdown"
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
 
 
 const log = Log.create({ service: "config" })
 const log = Log.create({ service: "config" })
 
 
-export const Info = z.object({
-  template: z.string(),
-  description: z.string().optional(),
-  agent: z.string().optional(),
-  model: ConfigModelID.optional(),
-  subtask: z.boolean().optional(),
-})
+export const Info = Schema.Struct({
+  template: Schema.String,
+  description: Schema.optional(Schema.String),
+  agent: Schema.optional(Schema.String),
+  model: Schema.optional(ConfigModelID),
+  subtask: Schema.optional(Schema.Boolean),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>
 
 
 export async function load(dir: string) {
 export async function load(dir: string) {
   const result: Record<string, Info> = {}
   const result: Record<string, Info> = {}
@@ -49,7 +51,7 @@ export async function load(dir: string) {
       ...md.data,
       ...md.data,
       template: md.content.trim(),
       template: md.content.trim(),
     }
     }
-    const parsed = Info.safeParse(config)
+    const parsed = Info.zod.safeParse(config)
     if (parsed.success) {
     if (parsed.success) {
       result[config.name] = parsed.data
       result[config.name] = parsed.data
       continue
       continue

+ 17 - 12
packages/opencode/src/config/config.ts

@@ -97,10 +97,10 @@ export const Info = z
     logLevel: Log.Level.optional().describe("Log level"),
     logLevel: Log.Level.optional().describe("Log level"),
     server: Server.optional().describe("Server configuration for opencode serve and web commands"),
     server: Server.optional().describe("Server configuration for opencode serve and web commands"),
     command: z
     command: z
-      .record(z.string(), ConfigCommand.Info)
+      .record(z.string(), ConfigCommand.Info.zod)
       .optional()
       .optional()
       .describe("Command configuration, see https://opencode.ai/docs/commands"),
       .describe("Command configuration, see https://opencode.ai/docs/commands"),
-    skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
+    skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
     watcher: z
     watcher: z
       .object({
       .object({
         ignore: z.array(z.string()).optional(),
         ignore: z.array(z.string()).optional(),
@@ -113,7 +113,7 @@ export const Info = z
         "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
         "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
       ),
       ),
     // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
     // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
-    plugin: ConfigPlugin.Spec.array().optional(),
+    plugin: ConfigPlugin.Spec.zod.array().optional(),
     share: z
     share: z
       .enum(["manual", "auto", "disabled"])
       .enum(["manual", "auto", "disabled"])
       .optional()
       .optional()
@@ -135,10 +135,10 @@ export const Info = z
       .array(z.string())
       .array(z.string())
       .optional()
       .optional()
       .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
       .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
-    model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
-    small_model: ConfigModelID.describe(
-      "Small model to use for tasks like title generation in the format of provider/model",
-    ).optional(),
+    model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
+    small_model: ConfigModelID.zod
+      .describe("Small model to use for tasks like title generation in the format of provider/model")
+      .optional(),
     default_agent: z
     default_agent: z
       .string()
       .string()
       .optional()
       .optional()
@@ -171,14 +171,14 @@ export const Info = z
       .optional()
       .optional()
       .describe("Agent configuration, see https://opencode.ai/docs/agents"),
       .describe("Agent configuration, see https://opencode.ai/docs/agents"),
     provider: z
     provider: z
-      .record(z.string(), ConfigProvider.Info)
+      .record(z.string(), ConfigProvider.Info.zod)
       .optional()
       .optional()
       .describe("Custom provider configurations and model overrides"),
       .describe("Custom provider configurations and model overrides"),
     mcp: z
     mcp: z
       .record(
       .record(
         z.string(),
         z.string(),
         z.union([
         z.union([
-          ConfigMCP.Info,
+          ConfigMCP.Info.zod,
           z
           z
             .object({
             .object({
               enabled: z.boolean(),
               enabled: z.boolean(),
@@ -188,8 +188,8 @@ export const Info = z
       )
       )
       .optional()
       .optional()
       .describe("MCP (Model Context Protocol) server configurations"),
       .describe("MCP (Model Context Protocol) server configurations"),
-    formatter: ConfigFormatter.Info.optional(),
-    lsp: ConfigLSP.Info.optional(),
+    formatter: ConfigFormatter.Info.zod.optional(),
+    lsp: ConfigLSP.Info.zod.optional(),
     instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
     instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
     layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
     layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
     permission: ConfigPermission.Info.optional(),
     permission: ConfigPermission.Info.optional(),
@@ -518,7 +518,12 @@ export const layer = Layer.effect(
 
 
           const dep = yield* npmSvc
           const dep = yield* npmSvc
             .install(dir, {
             .install(dir, {
-              add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+              add: [
+                {
+                  name: "@opencode-ai/plugin",
+                  version: InstallationLocal ? undefined : InstallationVersion,
+                },
+              ],
             })
             })
             .pipe(
             .pipe(
               Effect.exit,
               Effect.exit,

+ 11 - 10
packages/opencode/src/config/console-state.ts

@@ -1,15 +1,16 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
 
 
-export const ConsoleState = z.object({
-  consoleManagedProviders: z.array(z.string()),
-  activeOrgName: z.string().optional(),
-  switchableOrgCount: z.number().int().nonnegative(),
-})
-
-export type ConsoleState = z.infer<typeof ConsoleState>
+export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
+  consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
+  activeOrgName: Schema.optional(Schema.String),
+  switchableOrgCount: Schema.Number,
+}) {
+  static readonly zod = zod(this)
+}
 
 
-export const emptyConsoleState: ConsoleState = {
+export const emptyConsoleState: ConsoleState = ConsoleState.make({
   consoleManagedProviders: [],
   consoleManagedProviders: [],
   activeOrgName: undefined,
   activeOrgName: undefined,
   switchableOrgCount: 0,
   switchableOrgCount: 0,
-}
+})

+ 13 - 9
packages/opencode/src/config/formatter.ts

@@ -1,13 +1,17 @@
 export * as ConfigFormatter from "./formatter"
 export * as ConfigFormatter from "./formatter"
 
 
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
-export const Entry = z.object({
-  disabled: z.boolean().optional(),
-  command: z.array(z.string()).optional(),
-  environment: z.record(z.string(), z.string()).optional(),
-  extensions: z.array(z.string()).optional(),
-})
+export const Entry = Schema.Struct({
+  disabled: Schema.optional(Schema.Boolean),
+  command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+  environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+  extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
+  withStatics((s) => ({ zod: zod(s) })),
+)
+export type Info = Schema.Schema.Type<typeof Info>

+ 35 - 27
packages/opencode/src/config/lsp.ts

@@ -1,37 +1,45 @@
 export * as ConfigLSP from "./lsp"
 export * as ConfigLSP from "./lsp"
 
 
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import * as LSPServer from "../lsp/server"
 import * as LSPServer from "../lsp/server"
 
 
-export const Disabled = z.object({
-  disabled: z.literal(true),
-})
+export const Disabled = Schema.Struct({
+  disabled: Schema.Literal(true),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export const Entry = z.union([
+export const Entry = Schema.Union([
   Disabled,
   Disabled,
-  z.object({
-    command: z.array(z.string()),
-    extensions: z.array(z.string()).optional(),
-    disabled: z.boolean().optional(),
-    env: z.record(z.string(), z.string()).optional(),
-    initialization: z.record(z.string(), z.any()).optional(),
+  Schema.Struct({
+    command: Schema.mutable(Schema.Array(Schema.String)),
+    extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+    disabled: Schema.optional(Schema.Boolean),
+    env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+    initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
   }),
   }),
-])
+]).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
-  (data) => {
-    if (typeof data === "boolean") return true
-    const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
+/**
+ * For custom (non-builtin) LSP server entries, `extensions` is required so the
+ * client knows which files the server should attach to. Builtin server IDs and
+ * explicitly disabled entries are exempt.
+ */
+export const requiresExtensionsForCustomServers = Schema.makeFilter<
+  boolean | Record<string, Schema.Schema.Type<typeof Entry>>
+>((data) => {
+  if (typeof data === "boolean") return undefined
+  const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
+  const ok = Object.entries(data).every(([id, config]) => {
+    if ("disabled" in config && config.disabled) return true
+    if (serverIds.has(id)) return true
+    return "extensions" in config && Boolean(config.extensions)
+  })
+  return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
+})
 
 
-    return Object.entries(data).every(([id, config]) => {
-      if (config.disabled) return true
-      if (serverIds.has(id)) return true
-      return Boolean(config.extensions)
-    })
-  },
-  {
-    error: "For custom LSP servers, 'extensions' array is required.",
-  },
-)
+export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+  .check(requiresExtensionsForCustomServers)
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>

+ 56 - 62
packages/opencode/src/config/mcp.ts

@@ -1,68 +1,62 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
-export const Local = z
-  .object({
-    type: z.literal("local").describe("Type of MCP server connection"),
-    command: z.string().array().describe("Command and arguments to run the MCP server"),
-    environment: z
-      .record(z.string(), z.string())
-      .optional()
-      .describe("Environment variables to set when running the MCP server"),
-    enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
-    timeout: z
-      .number()
-      .int()
-      .positive()
-      .optional()
-      .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
-  })
-  .strict()
-  .meta({
-    ref: "McpLocalConfig",
-  })
+export class Local extends Schema.Class<Local>("McpLocalConfig")({
+  type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
+  command: Schema.mutable(Schema.Array(Schema.String)).annotate({
+    description: "Command and arguments to run the MCP server",
+  }),
+  environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
+    description: "Environment variables to set when running the MCP server",
+  }),
+  enabled: Schema.optional(Schema.Boolean).annotate({
+    description: "Enable or disable the MCP server on startup",
+  }),
+  timeout: Schema.optional(Schema.Number).annotate({
+    description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
+  }),
+}) {
+  static readonly zod = zod(this)
+}
 
 
-export const OAuth = z
-  .object({
-    clientId: z
-      .string()
-      .optional()
-      .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
-    clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
-    scope: z.string().optional().describe("OAuth scopes to request during authorization"),
-    redirectUri: z
-      .string()
-      .optional()
-      .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
-  })
-  .strict()
-  .meta({
-    ref: "McpOAuthConfig",
-  })
-export type OAuth = z.infer<typeof OAuth>
+export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
+  clientId: Schema.optional(Schema.String).annotate({
+    description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
+  }),
+  clientSecret: Schema.optional(Schema.String).annotate({
+    description: "OAuth client secret (if required by the authorization server)",
+  }),
+  scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
+  redirectUri: Schema.optional(Schema.String).annotate({
+    description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
+  }),
+}) {
+  static readonly zod = zod(this)
+}
 
 
-export const Remote = z
-  .object({
-    type: z.literal("remote").describe("Type of MCP server connection"),
-    url: z.string().describe("URL of the remote MCP server"),
-    enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
-    headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
-    oauth: z
-      .union([OAuth, z.literal(false)])
-      .optional()
-      .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
-    timeout: z
-      .number()
-      .int()
-      .positive()
-      .optional()
-      .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
-  })
-  .strict()
-  .meta({
-    ref: "McpRemoteConfig",
-  })
+export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
+  type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
+  url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
+  enabled: Schema.optional(Schema.Boolean).annotate({
+    description: "Enable or disable the MCP server on startup",
+  }),
+  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
+    description: "Headers to send with the request",
+  }),
+  oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
+    description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
+  }),
+  timeout: Schema.optional(Schema.Number).annotate({
+    description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
+  }),
+}) {
+  static readonly zod = zod(this)
+}
 
 
-export const Info = z.discriminatedUnion("type", [Local, Remote])
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Union([Local, Remote])
+  .annotate({ discriminator: "type" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 
 export * as ConfigMCP from "./mcp"
 export * as ConfigMCP from "./mcp"

+ 12 - 1
packages/opencode/src/config/model-id.ts

@@ -1,3 +1,14 @@
+import { Schema } from "effect"
 import z from "zod"
 import z from "zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
-export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+// The original Zod schema carried an external $ref pointing at the models.dev
+// JSON schema. That external reference is not a named SDK component — it is a
+// literal pointer to an outside schema — so the walker cannot re-derive it
+// from AST metadata. Preserve the exact original Zod via ZodOverride.
+export const ConfigModelID = Schema.String.annotate({
+  [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+
+export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>

+ 33 - 30
packages/opencode/src/config/permission.ts

@@ -1,5 +1,8 @@
 export * as ConfigPermission from "./permission"
 export * as ConfigPermission from "./permission"
+import { Schema } from "effect"
 import z from "zod"
 import z from "zod"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
 const permissionPreprocess = (val: unknown) => {
 const permissionPreprocess = (val: unknown) => {
   if (typeof val === "object" && val !== null && !Array.isArray(val)) {
   if (typeof val === "object" && val !== null && !Array.isArray(val)) {
@@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => {
   return val
   return val
 }
 }
 
 
-export const Action = z.enum(["ask", "allow", "deny"]).meta({
-  ref: "PermissionActionConfig",
-})
-export type Action = z.infer<typeof Action>
+export const Action = Schema.Literals(["ask", "allow", "deny"])
+  .annotate({ identifier: "PermissionActionConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Action = Schema.Schema.Type<typeof Action>
 
 
-export const Object = z.record(z.string(), Action).meta({
-  ref: "PermissionObjectConfig",
-})
-export type Object = z.infer<typeof Object>
+export const Object = Schema.Record(Schema.String, Action)
+  .annotate({ identifier: "PermissionObjectConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Object = Schema.Schema.Type<typeof Object>
 
 
-export const Rule = z.union([Action, Object]).meta({
-  ref: "PermissionRuleConfig",
-})
-export type Rule = z.infer<typeof Rule>
+export const Rule = Schema.Union([Action, Object])
+  .annotate({ identifier: "PermissionRuleConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Rule = Schema.Schema.Type<typeof Rule>
 
 
 const transform = (x: unknown): Record<string, Rule> => {
 const transform = (x: unknown): Record<string, Rule> => {
   if (typeof x === "string") return { "*": x as Action }
   if (typeof x === "string") return { "*": x as Action }
@@ -41,25 +44,25 @@ export const Info = z
     z
     z
       .object({
       .object({
         __originalKeys: z.string().array().optional(),
         __originalKeys: z.string().array().optional(),
-        read: Rule.optional(),
-        edit: Rule.optional(),
-        glob: Rule.optional(),
-        grep: Rule.optional(),
-        list: Rule.optional(),
-        bash: Rule.optional(),
-        task: Rule.optional(),
-        external_directory: Rule.optional(),
-        todowrite: Action.optional(),
-        question: Action.optional(),
-        webfetch: Action.optional(),
-        websearch: Action.optional(),
-        codesearch: Action.optional(),
-        lsp: Rule.optional(),
-        doom_loop: Action.optional(),
-        skill: Rule.optional(),
+        read: Rule.zod.optional(),
+        edit: Rule.zod.optional(),
+        glob: Rule.zod.optional(),
+        grep: Rule.zod.optional(),
+        list: Rule.zod.optional(),
+        bash: Rule.zod.optional(),
+        task: Rule.zod.optional(),
+        external_directory: Rule.zod.optional(),
+        todowrite: Action.zod.optional(),
+        question: Action.zod.optional(),
+        webfetch: Action.zod.optional(),
+        websearch: Action.zod.optional(),
+        codesearch: Action.zod.optional(),
+        lsp: Rule.zod.optional(),
+        doom_loop: Action.zod.optional(),
+        skill: Rule.zod.optional(),
       })
       })
-      .catchall(Rule)
-      .or(Action),
+      .catchall(Rule.zod)
+      .or(Action.zod),
   )
   )
   .transform(transform)
   .transform(transform)
   .meta({
   .meta({

+ 9 - 5
packages/opencode/src/config/plugin.ts

@@ -1,16 +1,20 @@
 import { Glob } from "@opencode-ai/shared/util/glob"
 import { Glob } from "@opencode-ai/shared/util/glob"
-import z from "zod"
+import { Schema } from "effect"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import path from "path"
 import path from "path"
 
 
-const Options = z.record(z.string(), z.unknown())
-export type Options = z.infer<typeof Options>
+export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Options = Schema.Schema.Type<typeof Options>
 
 
 // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
 // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
 // It answers "what should we load?" but says nothing about where that value came from.
 // It answers "what should we load?" but says nothing about where that value came from.
-export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
-export type Spec = z.infer<typeof Spec>
+export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
+  withStatics((s) => ({ zod: zod(s) })),
+)
+export type Spec = Schema.Schema.Type<typeof Spec>
 
 
 export type Scope = "global" | "local"
 export type Scope = "global" | "local"
 
 

+ 110 - 112
packages/opencode/src/config/provider.ts

@@ -1,120 +1,118 @@
+import { Schema } from "effect"
 import z from "zod"
 import z from "zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
-export const Model = z
-  .object({
-    id: z.string(),
-    name: z.string(),
-    family: z.string().optional(),
-    release_date: z.string(),
-    attachment: z.boolean(),
-    reasoning: z.boolean(),
-    temperature: z.boolean(),
-    tool_call: z.boolean(),
-    interleaved: z
-      .union([
-        z.literal(true),
-        z
-          .object({
-            field: z.enum(["reasoning_content", "reasoning_details"]),
-          })
-          .strict(),
-      ])
-      .optional(),
-    cost: z
-      .object({
-        input: z.number(),
-        output: z.number(),
-        cache_read: z.number().optional(),
-        cache_write: z.number().optional(),
-        context_over_200k: z
-          .object({
-            input: z.number(),
-            output: z.number(),
-            cache_read: z.number().optional(),
-            cache_write: z.number().optional(),
-          })
-          .optional(),
-      })
-      .optional(),
-    limit: z.object({
-      context: z.number(),
-      input: z.number().optional(),
-      output: z.number(),
+// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0).
+const PositiveInt = Schema.Number.annotate({
+  [ZodOverride]: z.number().int().positive(),
+})
+
+export const Model = Schema.Struct({
+  id: Schema.optional(Schema.String),
+  name: Schema.optional(Schema.String),
+  family: Schema.optional(Schema.String),
+  release_date: Schema.optional(Schema.String),
+  attachment: Schema.optional(Schema.Boolean),
+  reasoning: Schema.optional(Schema.Boolean),
+  temperature: Schema.optional(Schema.Boolean),
+  tool_call: Schema.optional(Schema.Boolean),
+  interleaved: Schema.optional(
+    Schema.Union([
+      Schema.Literal(true),
+      Schema.Struct({
+        field: Schema.Literals(["reasoning_content", "reasoning_details"]),
+      }),
+    ]),
+  ),
+  cost: Schema.optional(
+    Schema.Struct({
+      input: Schema.Number,
+      output: Schema.Number,
+      cache_read: Schema.optional(Schema.Number),
+      cache_write: Schema.optional(Schema.Number),
+      context_over_200k: Schema.optional(
+        Schema.Struct({
+          input: Schema.Number,
+          output: Schema.Number,
+          cache_read: Schema.optional(Schema.Number),
+          cache_write: Schema.optional(Schema.Number),
+        }),
+      ),
+    }),
+  ),
+  limit: Schema.optional(
+    Schema.Struct({
+      context: Schema.Number,
+      input: Schema.optional(Schema.Number),
+      output: Schema.Number,
     }),
     }),
-    modalities: z
-      .object({
-        input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
-        output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
-      })
-      .optional(),
-    experimental: z.boolean().optional(),
-    status: z.enum(["alpha", "beta", "deprecated"]).optional(),
-    provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
-    options: z.record(z.string(), z.any()),
-    headers: z.record(z.string(), z.string()).optional(),
-    variants: z
-      .record(
-        z.string(),
-        z
-          .object({
-            disabled: z.boolean().optional().describe("Disable this variant for the model"),
-          })
-          .catchall(z.any()),
-      )
-      .optional()
-      .describe("Variant-specific configuration"),
-  })
-  .partial()
+  ),
+  modalities: Schema.optional(
+    Schema.Struct({
+      input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
+      output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
+    }),
+  ),
+  experimental: Schema.optional(Schema.Boolean),
+  status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
+  provider: Schema.optional(
+    Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
+  ),
+  options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+  variants: Schema.optional(
+    Schema.Record(
+      Schema.String,
+      Schema.StructWithRest(
+        Schema.Struct({
+          disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
+        }),
+        [Schema.Record(Schema.String, Schema.Any)],
+      ),
+    ).annotate({ description: "Variant-specific configuration" }),
+  ),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export const Info = z
-  .object({
-    api: z.string().optional(),
-    name: z.string(),
-    env: z.array(z.string()),
-    id: z.string(),
-    npm: z.string().optional(),
-    whitelist: z.array(z.string()).optional(),
-    blacklist: z.array(z.string()).optional(),
-    options: z
-      .object({
-        apiKey: z.string().optional(),
-        baseURL: z.string().optional(),
-        enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
-        setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
-        timeout: z
-          .union([
-            z
-              .number()
-              .int()
-              .positive()
-              .describe(
-                "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
-              ),
-            z.literal(false).describe("Disable timeout for this provider entirely."),
-          ])
-          .optional()
-          .describe(
+export class Info extends Schema.Class<Info>("ProviderConfig")({
+  api: Schema.optional(Schema.String),
+  name: Schema.optional(Schema.String),
+  env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+  id: Schema.optional(Schema.String),
+  npm: Schema.optional(Schema.String),
+  whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+  blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+  options: Schema.optional(
+    Schema.StructWithRest(
+      Schema.Struct({
+        apiKey: Schema.optional(Schema.String),
+        baseURL: Schema.optional(Schema.String),
+        enterpriseUrl: Schema.optional(Schema.String).annotate({
+          description: "GitHub Enterprise URL for copilot authentication",
+        }),
+        setCacheKey: Schema.optional(Schema.Boolean).annotate({
+          description: "Enable promptCacheKey for this provider (default false)",
+        }),
+        timeout: Schema.optional(
+          Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
+            description:
+              "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
+          }),
+        ).annotate({
+          description:
             "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
             "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
-          ),
-        chunkTimeout: z
-          .number()
-          .int()
-          .positive()
-          .optional()
-          .describe(
+        }),
+        chunkTimeout: Schema.optional(PositiveInt).annotate({
+          description:
             "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
             "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
-          ),
-      })
-      .catchall(z.any())
-      .optional(),
-    models: z.record(z.string(), Model).optional(),
-  })
-  .partial()
-  .strict()
-  .meta({
-    ref: "ProviderConfig",
-  })
-
-export type Info = z.infer<typeof Info>
+        }),
+      }),
+      [Schema.Record(Schema.String, Schema.Any)],
+    ),
+  ),
+  models: Schema.optional(Schema.Record(Schema.String, Model)),
+}) {
+  static readonly zod = zod(this)
+}
 
 
 export * as ConfigProvider from "./provider"
 export * as ConfigProvider from "./provider"

+ 12 - 9
packages/opencode/src/config/skills.ts

@@ -1,13 +1,16 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 
-export const Info = z.object({
-  paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
-  urls: z
-    .array(z.string())
-    .optional()
-    .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
-})
+export const Info = Schema.Struct({
+  paths: Schema.optional(Schema.Array(Schema.String)).annotate({
+    description: "Additional paths to skill folders",
+  }),
+  urls: Schema.optional(Schema.Array(Schema.String)).annotate({
+    description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
+  }),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 
 
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>
 
 
 export * as ConfigSkills from "./skills"
 export * as ConfigSkills from "./skills"

+ 11 - 5
packages/opencode/src/npm/index.ts

@@ -25,7 +25,12 @@ export interface Interface {
   readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
   readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
   readonly install: (
   readonly install: (
     dir: string,
     dir: string,
-    input?: { add: string[] },
+    input?: {
+      add: {
+        name: string
+        version?: string
+      }[]
+    },
   ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
   ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
   readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
   readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
   readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
   readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
@@ -137,17 +142,18 @@ export const layer = Layer.effect(
       return resolveEntryPoint(first.name, first.path)
       return resolveEntryPoint(first.name, first.path)
     }, Effect.scoped)
     }, Effect.scoped)
 
 
-    const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
+    const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
       const canWrite = yield* afs.access(dir, { writable: true }).pipe(
       const canWrite = yield* afs.access(dir, { writable: true }).pipe(
         Effect.as(true),
         Effect.as(true),
         Effect.orElseSucceed(() => false),
         Effect.orElseSucceed(() => false),
       )
       )
       if (!canWrite) return
       if (!canWrite) return
 
 
+      const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
       yield* Effect.gen(function* () {
       yield* Effect.gen(function* () {
         const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
         const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
         if (!nodeModulesExists) {
         if (!nodeModulesExists) {
-          yield* reify({ add: input?.add, dir })
+          yield* reify({ add, dir })
           return
           return
         }
         }
       }).pipe(Effect.withSpan("Npm.checkNodeModules"))
       }).pipe(Effect.withSpan("Npm.checkNodeModules"))
@@ -163,7 +169,7 @@ export const layer = Layer.effect(
           ...Object.keys(pkgAny?.devDependencies || {}),
           ...Object.keys(pkgAny?.devDependencies || {}),
           ...Object.keys(pkgAny?.peerDependencies || {}),
           ...Object.keys(pkgAny?.peerDependencies || {}),
           ...Object.keys(pkgAny?.optionalDependencies || {}),
           ...Object.keys(pkgAny?.optionalDependencies || {}),
-          ...(input?.add || []),
+          ...(input?.add || []).map((pkg) => pkg.name),
         ])
         ])
 
 
         const root = lockAny?.packages?.[""] || {}
         const root = lockAny?.packages?.[""] || {}
@@ -176,7 +182,7 @@ export const layer = Layer.effect(
 
 
         for (const name of declared) {
         for (const name of declared) {
           if (!locked.has(name)) {
           if (!locked.has(name)) {
-            yield* reify({ dir, add: input?.add })
+            yield* reify({ dir, add })
             return
             return
           }
           }
         }
         }

+ 0 - 1
packages/opencode/src/plugin/github-copilot/copilot.ts

@@ -1,6 +1,5 @@
 import type { Hooks, PluginInput } from "@opencode-ai/plugin"
 import type { Hooks, PluginInput } from "@opencode-ai/plugin"
 import type { Model } from "@opencode-ai/sdk/v2"
 import type { Model } from "@opencode-ai/sdk/v2"
-import { Installation } from "@/installation"
 import { InstallationVersion } from "@/installation/version"
 import { InstallationVersion } from "@/installation/version"
 import { iife } from "@/util/iife"
 import { iife } from "@/util/iife"
 import { Log } from "../../util"
 import { Log } from "../../util"

+ 8 - 1
packages/opencode/src/plugin/github-copilot/models.ts

@@ -10,6 +10,11 @@ export const schema = z.object({
       // every version looks like: `{model.id}-YYYY-MM-DD`
       // every version looks like: `{model.id}-YYYY-MM-DD`
       version: z.string(),
       version: z.string(),
       supported_endpoints: z.array(z.string()).optional(),
       supported_endpoints: z.array(z.string()).optional(),
+      policy: z
+        .object({
+          state: z.string().optional(),
+        })
+        .optional(),
       capabilities: z.object({
       capabilities: z.object({
         family: z.string(),
         family: z.string(),
         limits: z.object({
         limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
   })
   })
 
 
   const result = { ...existing }
   const result = { ...existing }
-  const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
+  const remote = new Map(
+    data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
+  )
 
 
   // prune existing models whose api.id isn't in the endpoint response
   // prune existing models whose api.id isn't in the endpoint response
   for (const [key, model] of Object.entries(result)) {
   for (const [key, model] of Object.entries(result)) {

+ 42 - 0
packages/opencode/src/plugin/loader.ts

@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
 import { InstallationVersion } from "@/installation/version"
 import { InstallationVersion } from "@/installation/version"
 
 
 export namespace PluginLoader {
 export namespace PluginLoader {
+  // A normalized plugin declaration derived from config before any filesystem or npm work happens.
   export type Plan = {
   export type Plan = {
     spec: string
     spec: string
     options: ConfigPlugin.Options | undefined
     options: ConfigPlugin.Options | undefined
     deprecated: boolean
     deprecated: boolean
   }
   }
+
+  // A plugin that has been resolved to a concrete target and entrypoint on disk.
   export type Resolved = Plan & {
   export type Resolved = Plan & {
     source: PluginSource
     source: PluginSource
     target: string
     target: string
     entry: string
     entry: string
     pkg?: PluginPackage
     pkg?: PluginPackage
   }
   }
+
+  // A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
   export type Missing = Plan & {
   export type Missing = Plan & {
     source: PluginSource
     source: PluginSource
     target: string
     target: string
     pkg?: PluginPackage
     pkg?: PluginPackage
     message: string
     message: string
   }
   }
+
+  // A resolved plugin whose module has been imported successfully.
   export type Loaded = Resolved & {
   export type Loaded = Resolved & {
     mod: Record<string, unknown>
     mod: Record<string, unknown>
   }
   }
 
 
   type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
   type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
   type Report = {
   type Report = {
+    // Called before each attempt so callers can log initial load attempts and retries uniformly.
     start?: (candidate: Candidate, retry: boolean) => void
     start?: (candidate: Candidate, retry: boolean) => void
+    // Called when the package exists but does not provide the requested entrypoint.
     missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
     missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
+    // Called for operational failures such as install, compatibility, or dynamic import errors.
     error?: (
     error?: (
       candidate: Candidate,
       candidate: Candidate,
       retry: boolean,
       retry: boolean,
@@ -46,11 +56,16 @@ export namespace PluginLoader {
     ) => void
     ) => void
   }
   }
 
 
+  // Normalize a config item into the loader's internal representation.
   function plan(item: ConfigPlugin.Spec): Plan {
   function plan(item: ConfigPlugin.Spec): Plan {
     const spec = ConfigPlugin.pluginSpecifier(item)
     const spec = ConfigPlugin.pluginSpecifier(item)
     return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
     return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
   }
   }
 
 
+  // Resolve a configured plugin into a concrete entrypoint that can later be imported.
+  //
+  // The stages here intentionally separate install/target resolution, entrypoint detection,
+  // and compatibility checks so callers can report the exact reason a plugin was skipped.
   export async function resolve(
   export async function resolve(
     plan: Plan,
     plan: Plan,
     kind: PluginKind,
     kind: PluginKind,
@@ -59,6 +74,7 @@ export namespace PluginLoader {
     | { ok: false; stage: "missing"; value: Missing }
     | { ok: false; stage: "missing"; value: Missing }
     | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
     | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
   > {
   > {
+    // First make sure the plugin exists locally, installing npm plugins on demand.
     let target = ""
     let target = ""
     try {
     try {
       target = await resolvePluginTarget(plan.spec)
       target = await resolvePluginTarget(plan.spec)
@@ -67,6 +83,7 @@ export namespace PluginLoader {
     }
     }
     if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
     if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
 
 
+    // Then inspect the target for the requested server/tui entrypoint.
     let base
     let base
     try {
     try {
       base = await createPluginEntry(plan.spec, target, kind)
       base = await createPluginEntry(plan.spec, target, kind)
@@ -86,6 +103,8 @@ export namespace PluginLoader {
         },
         },
       }
       }
 
 
+    // npm plugins can declare which opencode versions they support; file plugins are treated
+    // as local development code and skip this compatibility gate.
     if (base.source === "npm") {
     if (base.source === "npm") {
       try {
       try {
         await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
         await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@@ -96,6 +115,7 @@ export namespace PluginLoader {
     return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
     return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
   }
   }
 
 
+  // Import the resolved module only after all earlier validation has succeeded.
   export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
   export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
     let mod
     let mod
     try {
     try {
@@ -107,6 +127,8 @@ export namespace PluginLoader {
     return { ok: true, value: { ...row, mod } }
     return { ok: true, value: { ...row, mod } }
   }
   }
 
 
+  // Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
+  // import the module, and finally let the caller transform the loaded plugin into any result type.
   async function attempt<R>(
   async function attempt<R>(
     candidate: Candidate,
     candidate: Candidate,
     kind: PluginKind,
     kind: PluginKind,
@@ -116,11 +138,17 @@ export namespace PluginLoader {
     report: Report | undefined,
     report: Report | undefined,
   ): Promise<R | undefined> {
   ): Promise<R | undefined> {
     const plan = candidate.plan
     const plan = candidate.plan
+
+    // Deprecated plugin packages are silently ignored because they are now built in.
     if (plan.deprecated) return
     if (plan.deprecated) return
+
     report?.start?.(candidate, retry)
     report?.start?.(candidate, retry)
+
     const resolved = await resolve(plan, kind)
     const resolved = await resolve(plan, kind)
     if (!resolved.ok) {
     if (!resolved.ok) {
       if (resolved.stage === "missing") {
       if (resolved.stage === "missing") {
+        // Missing entrypoints are handled separately so callers can still inspect package metadata,
+        // for example to load theme files from a tui plugin package that has no code entrypoint.
         if (missing) {
         if (missing) {
           const value = await missing(resolved.value, candidate.origin, retry)
           const value = await missing(resolved.value, candidate.origin, retry)
           if (value !== undefined) return value
           if (value !== undefined) return value
@@ -131,11 +159,15 @@ export namespace PluginLoader {
       report?.error?.(candidate, retry, resolved.stage, resolved.error)
       report?.error?.(candidate, retry, resolved.stage, resolved.error)
       return
       return
     }
     }
+
     const loaded = await load(resolved.value)
     const loaded = await load(resolved.value)
     if (!loaded.ok) {
     if (!loaded.ok) {
       report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
       report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
       return
       return
     }
     }
+
+    // The default behavior is to return the successfully loaded plugin as-is, but callers can
+    // provide a finisher to adapt the result into a more specific runtime shape.
     if (!finish) return loaded.value as R
     if (!finish) return loaded.value as R
     return finish(loaded.value, candidate.origin, retry)
     return finish(loaded.value, candidate.origin, retry)
   }
   }
@@ -149,6 +181,11 @@ export namespace PluginLoader {
     report?: Report
     report?: Report
   }
   }
 
 
+  // Resolve and load all configured plugins in parallel.
+  //
+  // If `wait` is provided, file-based plugins that initially failed are retried once after the
+  // caller finishes preparing dependencies. This supports local plugins that depend on an install
+  // step happening elsewhere before their entrypoint becomes loadable.
   export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
   export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
     const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
     const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
     const list: Array<Promise<R | undefined>> = []
     const list: Array<Promise<R | undefined>> = []
@@ -160,6 +197,9 @@ export namespace PluginLoader {
       let deps: Promise<void> | undefined
       let deps: Promise<void> | undefined
       for (let i = 0; i < candidates.length; i++) {
       for (let i = 0; i < candidates.length; i++) {
         if (out[i] !== undefined) continue
         if (out[i] !== undefined) continue
+
+        // Only local file plugins are retried. npm plugins already attempted installation during
+        // the first pass, while file plugins may need the caller's dependency preparation to finish.
         const candidate = candidates[i]
         const candidate = candidates[i]
         if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
         if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
         deps ??= input.wait()
         deps ??= input.wait()
@@ -167,6 +207,8 @@ export namespace PluginLoader {
         out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
         out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
       }
       }
     }
     }
+
+    // Drop skipped/failed entries while preserving the successful result order.
     const ready: R[] = []
     const ready: R[] = []
     for (const item of out) if (item !== undefined) ready.push(item)
     for (const item of out) if (item !== undefined) ready.push(item)
     return ready
     return ready

+ 0 - 2
packages/opencode/src/server/routes/control/index.ts

@@ -7,7 +7,6 @@ import { Hono } from "hono"
 import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
 import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
 import z from "zod"
 import z from "zod"
 import { errors } from "../../error"
 import { errors } from "../../error"
-import { WorkspaceRoutes } from "./workspace"
 
 
 export function ControlPlaneRoutes(): Hono {
 export function ControlPlaneRoutes(): Hono {
   const app = new Hono()
   const app = new Hono()
@@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
         return c.json(true)
         return c.json(true)
       },
       },
     )
     )
-    .route("/experimental/workspace", WorkspaceRoutes())
 }
 }

+ 7 - 6
packages/opencode/src/server/routes/instance/config.ts

@@ -5,7 +5,6 @@ import { Config } from "@/config"
 import { Provider } from "@/provider"
 import { Provider } from "@/provider"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
-import { AppRuntime } from "@/effect/app-runtime"
 import { jsonRequest } from "./trace"
 import { jsonRequest } from "./trace"
 
 
 export const ConfigRoutes = lazy(() =>
 export const ConfigRoutes = lazy(() =>
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", Config.Info),
       validator("json", Config.Info),
-      async (c) => {
-        const config = c.req.valid("json")
-        await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
-        return c.json(config)
-      },
+      async (c) =>
+        jsonRequest("ConfigRoutes.update", c, function* () {
+          const config = c.req.valid("json")
+          const cfg = yield* Config.Service
+          yield* cfg.update(config)
+          return config
+        }),
     )
     )
     .get(
     .get(
       "/providers",
       "/providers",

+ 80 - 92
packages/opencode/src/server/routes/instance/experimental.ts

@@ -12,11 +12,11 @@ import { Config } from "@/config"
 import { ConsoleState } from "@/config/console-state"
 import { ConsoleState } from "@/config/console-state"
 import { Account } from "@/account/account"
 import { Account } from "@/account/account"
 import { AccountID, OrgID } from "@/account/schema"
 import { AccountID, OrgID } from "@/account/schema"
-import { AppRuntime } from "@/effect/app-runtime"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { Effect, Option } from "effect"
 import { Effect, Option } from "effect"
 import { Agent } from "@/agent/agent"
 import { Agent } from "@/agent/agent"
+import { jsonRequest, runRequest } from "./trace"
 
 
 const ConsoleOrgOption = z.object({
 const ConsoleOrgOption = z.object({
   accountID: z.string(),
   accountID: z.string(),
@@ -49,28 +49,24 @@ export const ExperimentalRoutes = lazy(() =>
             description: "Active Console provider metadata",
             description: "Active Console provider metadata",
             content: {
             content: {
               "application/json": {
               "application/json": {
-                schema: resolver(ConsoleState),
+                schema: resolver(ConsoleState.zod),
               },
               },
             },
             },
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const result = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const config = yield* Config.Service
-            const account = yield* Account.Service
-            const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
-              concurrency: "unbounded",
-            })
-            return {
-              ...state,
-              switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
-            }
-          }),
-        )
-        return c.json(result)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.console.get", c, function* () {
+          const config = yield* Config.Service
+          const account = yield* Account.Service
+          const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
+            concurrency: "unbounded",
+          })
+          return {
+            ...state,
+            switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
+          }
+        }),
     )
     )
     .get(
     .get(
       "/console/orgs",
       "/console/orgs",
@@ -89,28 +85,25 @@ export const ExperimentalRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const orgs = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const account = yield* Account.Service
-            const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
-              concurrency: "unbounded",
-            })
-            const info = Option.getOrUndefined(active)
-            return 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,
-              })),
-            )
-          }),
-        )
-        return c.json({ orgs })
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
+          const account = yield* Account.Service
+          const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
+            concurrency: "unbounded",
+          })
+          const info = Option.getOrUndefined(active)
+          const 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,
+            })),
+          )
+          return { orgs }
+        }),
     )
     )
     .post(
     .post(
       "/console/switch",
       "/console/switch",
@@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", ConsoleSwitchBody),
       validator("json", ConsoleSwitchBody),
-      async (c) => {
-        const body = c.req.valid("json")
-        await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const account = yield* Account.Service
-            yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
-          }),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
+          const body = c.req.valid("json")
+          const account = yield* Account.Service
+          yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
+          return true
+        }),
     )
     )
     .get(
     .get(
       "/tool/ids",
       "/tool/ids",
@@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
           ...errors(400),
         },
         },
       }),
       }),
-      async (c) => {
-        const ids = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const registry = yield* ToolRegistry.Service
-            return yield* registry.ids()
-          }),
-        )
-        return c.json(ids)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
+          const registry = yield* ToolRegistry.Service
+          return yield* registry.ids()
+        }),
     )
     )
     .get(
     .get(
       "/tool",
       "/tool",
@@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() =>
       ),
       ),
       async (c) => {
       async (c) => {
         const { provider, model } = c.req.valid("query")
         const { provider, model } = c.req.valid("query")
-        const tools = await AppRuntime.runPromise(
+        const tools = await runRequest(
+          "ExperimentalRoutes.tool.list",
+          c,
           Effect.gen(function* () {
           Effect.gen(function* () {
             const agents = yield* Agent.Service
             const agents = yield* Agent.Service
             const registry = yield* ToolRegistry.Service
             const registry = yield* ToolRegistry.Service
@@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", Worktree.CreateInput.optional()),
       validator("json", Worktree.CreateInput.optional()),
-      async (c) => {
-        const body = c.req.valid("json")
-        const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
-        return c.json(worktree)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
+          const body = c.req.valid("json")
+          const svc = yield* Worktree.Service
+          return yield* svc.create(body)
+        }),
     )
     )
     .get(
     .get(
       "/worktree",
       "/worktree",
@@ -272,10 +261,11 @@ export const ExperimentalRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
-        return c.json(sandboxes)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
+          const svc = yield* Project.Service
+          return yield* svc.sandboxes(Instance.project.id)
+        }),
     )
     )
     .delete(
     .delete(
       "/worktree",
       "/worktree",
@@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", Worktree.RemoveInput),
       validator("json", Worktree.RemoveInput),
-      async (c) => {
-        const body = c.req.valid("json")
-        await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
-        await AppRuntime.runPromise(
-          Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
+          const body = c.req.valid("json")
+          const worktree = yield* Worktree.Service
+          const project = yield* Project.Service
+          yield* worktree.remove(body)
+          yield* project.removeSandbox(Instance.project.id, body.directory)
+          return true
+        }),
     )
     )
     .post(
     .post(
       "/worktree/reset",
       "/worktree/reset",
@@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", Worktree.ResetInput),
       validator("json", Worktree.ResetInput),
-      async (c) => {
-        const body = c.req.valid("json")
-        await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
+          const body = c.req.valid("json")
+          const svc = yield* Worktree.Service
+          yield* svc.reset(body)
+          return true
+        }),
     )
     )
     .get(
     .get(
       "/session",
       "/session",
@@ -406,15 +399,10 @@ export const ExperimentalRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(
-          await AppRuntime.runPromise(
-            Effect.gen(function* () {
-              const mcp = yield* MCP.Service
-              return yield* mcp.resources()
-            }),
-          ),
-        )
-      },
+      async (c) =>
+        jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
+          const mcp = yield* MCP.Service
+          return yield* mcp.resources()
+        }),
     ),
     ),
 )
 )

+ 34 - 54
packages/opencode/src/server/routes/instance/file.ts

@@ -1,13 +1,12 @@
 import { Hono } from "hono"
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import { describeRoute, validator, resolver } from "hono-openapi"
-import { Effect } from "effect"
 import z from "zod"
 import z from "zod"
-import { AppRuntime } from "@/effect/app-runtime"
 import { File } from "@/file"
 import { File } from "@/file"
 import { Ripgrep } from "@/file/ripgrep"
 import { Ripgrep } from "@/file/ripgrep"
 import { LSP } from "@/lsp"
 import { LSP } from "@/lsp"
 import { Instance } from "@/project/instance"
 import { Instance } from "@/project/instance"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 
 export const FileRoutes = lazy(() =>
 export const FileRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
           pattern: z.string(),
           pattern: z.string(),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const pattern = c.req.valid("query").pattern
-        const result = await AppRuntime.runPromise(
-          Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
-        )
-        return c.json(result.items)
-      },
+      async (c) =>
+        jsonRequest("FileRoutes.findText", c, function* () {
+          const pattern = c.req.valid("query").pattern
+          const svc = yield* Ripgrep.Service
+          const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
+          return result.items
+        }),
     )
     )
     .get(
     .get(
       "/find/file",
       "/find/file",
@@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
           limit: z.coerce.number().int().min(1).max(200).optional(),
           limit: z.coerce.number().int().min(1).max(200).optional(),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const query = c.req.valid("query").query
-        const dirs = c.req.valid("query").dirs
-        const type = c.req.valid("query").type
-        const limit = c.req.valid("query").limit
-        const results = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            return yield* File.Service.use((svc) =>
-              svc.search({
-                query,
-                limit: limit ?? 10,
-                dirs: dirs !== "false",
-                type,
-              }),
-            )
-          }),
-        )
-        return c.json(results)
-      },
+      async (c) =>
+        jsonRequest("FileRoutes.findFile", c, function* () {
+          const query = c.req.valid("query")
+          const svc = yield* File.Service
+          return yield* svc.search({
+            query: query.query,
+            limit: query.limit ?? 10,
+            dirs: query.dirs !== "false",
+            type: query.type,
+          })
+        }),
     )
     )
     .get(
     .get(
       "/find/symbol",
       "/find/symbol",
@@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
           path: z.string(),
           path: z.string(),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const path = c.req.valid("query").path
-        const content = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            return yield* File.Service.use((svc) => svc.list(path))
-          }),
-        )
-        return c.json(content)
-      },
+      async (c) =>
+        jsonRequest("FileRoutes.list", c, function* () {
+          const svc = yield* File.Service
+          return yield* svc.list(c.req.valid("query").path)
+        }),
     )
     )
     .get(
     .get(
       "/file/content",
       "/file/content",
@@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
           path: z.string(),
           path: z.string(),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const path = c.req.valid("query").path
-        const content = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            return yield* File.Service.use((svc) => svc.read(path))
-          }),
-        )
-        return c.json(content)
-      },
+      async (c) =>
+        jsonRequest("FileRoutes.read", c, function* () {
+          const svc = yield* File.Service
+          return yield* svc.read(c.req.valid("query").path)
+        }),
     )
     )
     .get(
     .get(
       "/file/status",
       "/file/status",
@@ -198,13 +181,10 @@ export const FileRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const content = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            return yield* File.Service.use((svc) => svc.status())
-          }),
-        )
-        return c.json(content)
-      },
+      async (c) =>
+        jsonRequest("FileRoutes.status", c, function* () {
+          const svc = yield* File.Service
+          return yield* svc.status()
+        }),
     ),
     ),
 )
 )

+ 41 - 51
packages/opencode/src/server/routes/instance/index.ts

@@ -15,7 +15,6 @@ import { Command } from "@/command"
 import { QuestionRoutes } from "./question"
 import { QuestionRoutes } from "./question"
 import { PermissionRoutes } from "./permission"
 import { PermissionRoutes } from "./permission"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
-import { WorkspaceID } from "@/control-plane/schema"
 import { ExperimentalHttpApiServer } from "./httpapi/server"
 import { ExperimentalHttpApiServer } from "./httpapi/server"
 import { ProjectRoutes } from "./project"
 import { ProjectRoutes } from "./project"
 import { SessionRoutes } from "./session"
 import { SessionRoutes } from "./session"
@@ -27,11 +26,11 @@ import { ExperimentalRoutes } from "./experimental"
 import { ProviderRoutes } from "./provider"
 import { ProviderRoutes } from "./provider"
 import { EventRoutes } from "./event"
 import { EventRoutes } from "./event"
 import { SyncRoutes } from "./sync"
 import { SyncRoutes } from "./sync"
-import { AppRuntime } from "@/effect/app-runtime"
 import { InstanceMiddleware } from "./middleware"
 import { InstanceMiddleware } from "./middleware"
+import { jsonRequest } from "./trace"
 
 
-export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: WorkspaceID): Hono => {
-  const app = new Hono().use(InstanceMiddleware(workspaceID))
+export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
+  const app = new Hono()
 
 
   if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
   if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
     const handler = ExperimentalHttpApiServer.webHandler().handler
     const handler = ExperimentalHttpApiServer.webHandler().handler
@@ -142,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(
-          await AppRuntime.runPromise(
-            Effect.gen(function* () {
-              const vcs = yield* Vcs.Service
-              const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
-                concurrency: 2,
-              })
-              return { branch, default_branch }
-            }),
-          ),
-        )
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.vcs.get", c, function* () {
+          const vcs = yield* Vcs.Service
+          const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
+            concurrency: 2,
+          })
+          return { branch, default_branch }
+        }),
     )
     )
     .get(
     .get(
       "/vcs/diff",
       "/vcs/diff",
@@ -179,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           mode: Vcs.Mode,
           mode: Vcs.Mode,
         }),
         }),
       ),
       ),
-      async (c) => {
-        return c.json(
-          await AppRuntime.runPromise(
-            Effect.gen(function* () {
-              const vcs = yield* Vcs.Service
-              return yield* vcs.diff(c.req.valid("query").mode)
-            }),
-          ),
-        )
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
+          const vcs = yield* Vcs.Service
+          return yield* vcs.diff(c.req.valid("query").mode)
+        }),
     )
     )
     .get(
     .get(
       "/command",
       "/command",
@@ -207,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
-        return c.json(commands)
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.command.list", c, function* () {
+          const svc = yield* Command.Service
+          return yield* svc.list()
+        }),
     )
     )
     .get(
     .get(
       "/agent",
       "/agent",
@@ -229,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
-        return c.json(modes)
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.agent.list", c, function* () {
+          const svc = yield* Agent.Service
+          return yield* svc.list()
+        }),
     )
     )
     .get(
     .get(
       "/skill",
       "/skill",
@@ -251,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const skills = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const skill = yield* Skill.Service
-            return yield* skill.all()
-          }),
-        )
-        return c.json(skills)
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.skill.list", c, function* () {
+          const skill = yield* Skill.Service
+          return yield* skill.all()
+        }),
     )
     )
     .get(
     .get(
       "/lsp",
       "/lsp",
@@ -278,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
-        return c.json(items)
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.lsp.status", c, function* () {
+          const lsp = yield* LSP.Service
+          return yield* lsp.status()
+        }),
     )
     )
     .get(
     .get(
       "/formatter",
       "/formatter",
@@ -300,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
-      },
+      async (c) =>
+        jsonRequest("InstanceRoutes.formatter.status", c, function* () {
+          const svc = yield* Format.Service
+          return yield* svc.status()
+        }),
     )
     )
 }
 }

+ 48 - 34
packages/opencode/src/server/routes/instance/mcp.ts

@@ -2,12 +2,11 @@ import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
 import z from "zod"
 import { MCP } from "@/mcp"
 import { MCP } from "@/mcp"
-import { Config } from "@/config"
 import { ConfigMCP } from "@/config/mcp"
 import { ConfigMCP } from "@/config/mcp"
-import { AppRuntime } from "@/effect/app-runtime"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { Effect } from "effect"
 import { Effect } from "effect"
+import { jsonRequest, runRequest } from "./trace"
 
 
 export const McpRoutes = lazy(() =>
 export const McpRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -28,9 +27,11 @@ export const McpRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.status", c, function* () {
+          const mcp = yield* MCP.Service
+          return yield* mcp.status()
+        }),
     )
     )
     .post(
     .post(
       "/",
       "/",
@@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
         "json",
         "json",
         z.object({
         z.object({
           name: z.string(),
           name: z.string(),
-          config: ConfigMCP.Info,
+          config: ConfigMCP.Info.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const { name, config } = c.req.valid("json")
-        const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
-        return c.json(result.status)
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.add", c, function* () {
+          const { name, config } = c.req.valid("json")
+          const mcp = yield* MCP.Service
+          const result = yield* mcp.add(name, config)
+          return result.status
+        }),
     )
     )
     .post(
     .post(
       "/:name/auth",
       "/:name/auth",
@@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
       }),
       }),
       async (c) => {
       async (c) => {
         const name = c.req.param("name")
         const name = c.req.param("name")
-        const result = await AppRuntime.runPromise(
+        const result = await runRequest(
+          "McpRoutes.auth.start",
+          c,
           Effect.gen(function* () {
           Effect.gen(function* () {
             const mcp = yield* MCP.Service
             const mcp = yield* MCP.Service
             const supports = yield* mcp.supportsOAuth(name)
             const supports = yield* mcp.supportsOAuth(name)
@@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
           code: z.string().describe("Authorization code from OAuth callback"),
           code: z.string().describe("Authorization code from OAuth callback"),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const name = c.req.param("name")
-        const { code } = c.req.valid("json")
-        const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
-        return c.json(status)
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.auth.callback", c, function* () {
+          const name = c.req.param("name")
+          const { code } = c.req.valid("json")
+          const mcp = yield* MCP.Service
+          return yield* mcp.finishAuth(name, code)
+        }),
     )
     )
     .post(
     .post(
       "/:name/auth/authenticate",
       "/:name/auth/authenticate",
@@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
       }),
       }),
       async (c) => {
       async (c) => {
         const name = c.req.param("name")
         const name = c.req.param("name")
-        const result = await AppRuntime.runPromise(
+        const result = await runRequest(
+          "McpRoutes.auth.authenticate",
+          c,
           Effect.gen(function* () {
           Effect.gen(function* () {
             const mcp = yield* MCP.Service
             const mcp = yield* MCP.Service
             const supports = yield* mcp.supportsOAuth(name)
             const supports = yield* mcp.supportsOAuth(name)
@@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
           ...errors(404),
           ...errors(404),
         },
         },
       }),
       }),
-      async (c) => {
-        const name = c.req.param("name")
-        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
-        return c.json({ success: true as const })
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.auth.remove", c, function* () {
+          const name = c.req.param("name")
+          const mcp = yield* MCP.Service
+          yield* mcp.removeAuth(name)
+          return { success: true as const }
+        }),
     )
     )
     .post(
     .post(
       "/:name/connect",
       "/:name/connect",
@@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("param", z.object({ name: z.string() })),
       validator("param", z.object({ name: z.string() })),
-      async (c) => {
-        const { name } = c.req.valid("param")
-        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.connect", c, function* () {
+          const { name } = c.req.valid("param")
+          const mcp = yield* MCP.Service
+          yield* mcp.connect(name)
+          return true
+        }),
     )
     )
     .post(
     .post(
       "/:name/disconnect",
       "/:name/disconnect",
@@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("param", z.object({ name: z.string() })),
       validator("param", z.object({ name: z.string() })),
-      async (c) => {
-        const { name } = c.req.valid("param")
-        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("McpRoutes.disconnect", c, function* () {
+          const { name } = c.req.valid("param")
+          const mcp = yield* MCP.Service
+          yield* mcp.disconnect(name)
+          return true
+        }),
     ),
     ),
 )
 )

+ 18 - 19
packages/opencode/src/server/routes/instance/permission.ts

@@ -1,11 +1,11 @@
 import { Hono } from "hono"
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
 import z from "zod"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Permission } from "@/permission"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { PermissionID } from "@/permission/schema"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 
 export const PermissionRoutes = lazy(() =>
 export const PermissionRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
       validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
-      async (c) => {
-        const params = c.req.valid("param")
-        const json = c.req.valid("json")
-        await AppRuntime.runPromise(
-          Permission.Service.use((svc) =>
-            svc.reply({
-              requestID: params.requestID,
-              reply: json.reply,
-              message: json.message,
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("PermissionRoutes.reply", c, function* () {
+          const params = c.req.valid("param")
+          const json = c.req.valid("json")
+          const svc = yield* Permission.Service
+          yield* svc.reply({
+            requestID: params.requestID,
+            reply: json.reply,
+            message: json.message,
+          })
+          return true
+        }),
     )
     )
     .get(
     .get(
       "/",
       "/",
@@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
-        return c.json(permissions)
-      },
+      async (c) =>
+        jsonRequest("PermissionRoutes.list", c, function* () {
+          const svc = yield* Permission.Service
+          return yield* svc.list()
+        }),
     ),
     ),
 )
 )

+ 11 - 7
packages/opencode/src/server/routes/instance/project.ts

@@ -9,6 +9,7 @@ import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { InstanceBootstrap } from "@/project/bootstrap"
 import { InstanceBootstrap } from "@/project/bootstrap"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AppRuntime } from "@/effect/app-runtime"
+import { jsonRequest, runRequest } from "./trace"
 
 
 export const ProjectRoutes = lazy(() =>
 export const ProjectRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
       async (c) => {
       async (c) => {
         const dir = Instance.directory
         const dir = Instance.directory
         const prev = Instance.project
         const prev = Instance.project
-        const next = await AppRuntime.runPromise(
+        const next = await runRequest(
+          "ProjectRoutes.initGit",
+          c,
           Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
           Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
         )
         )
         if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
         if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
@@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
       }),
       }),
       validator("param", z.object({ projectID: ProjectID.zod })),
       validator("param", z.object({ projectID: ProjectID.zod })),
       validator("json", Project.UpdateInput.omit({ projectID: true })),
       validator("json", Project.UpdateInput.omit({ projectID: true })),
-      async (c) => {
-        const projectID = c.req.valid("param").projectID
-        const body = c.req.valid("json")
-        const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
-        return c.json(project)
-      },
+      async (c) =>
+        jsonRequest("ProjectRoutes.update", c, function* () {
+          const projectID = c.req.valid("param").projectID
+          const body = c.req.valid("json")
+          const svc = yield* Project.Service
+          return yield* svc.update({ ...body, projectID })
+        }),
     ),
     ),
 )
 )

+ 53 - 64
packages/opencode/src/server/routes/instance/provider.ts

@@ -6,11 +6,11 @@ import { Provider } from "@/provider"
 import { ModelsDev } from "@/provider"
 import { ModelsDev } from "@/provider"
 import { ProviderAuth } from "@/provider"
 import { ProviderAuth } from "@/provider"
 import { ProviderID } from "@/provider/schema"
 import { ProviderID } from "@/provider/schema"
-import { AppRuntime } from "@/effect/app-runtime"
 import { mapValues } from "remeda"
 import { mapValues } from "remeda"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { Effect } from "effect"
 import { Effect } from "effect"
+import { jsonRequest } from "./trace"
 
 
 export const ProviderRoutes = lazy(() =>
 export const ProviderRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const result = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const svc = yield* Provider.Service
-            const cfg = yield* Config.Service
-            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
-              }
+      async (c) =>
+        jsonRequest("ProviderRoutes.list", c, function* () {
+          const svc = yield* Provider.Service
+          const cfg = yield* Config.Service
+          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* svc.list()
-            const providers = Object.assign(
-              mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
-              connected,
-            )
-            return {
-              all: Object.values(providers),
-              default: Provider.defaultModelIDs(providers),
-              connected: Object.keys(connected),
-            }
-          }),
-        )
-        return c.json({
-          all: result.all,
-          default: result.default,
-          connected: result.connected,
-        })
-      },
+          }
+          const connected = yield* svc.list()
+          const providers = Object.assign(
+            mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
+            connected,
+          )
+          return {
+            all: Object.values(providers),
+            default: Provider.defaultModelIDs(providers),
+            connected: Object.keys(connected),
+          }
+        }),
     )
     )
     .get(
     .get(
       "/auth",
       "/auth",
@@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
-      },
+      async (c) =>
+        jsonRequest("ProviderRoutes.auth", c, function* () {
+          const svc = yield* ProviderAuth.Service
+          return yield* svc.methods()
+        }),
     )
     )
     .post(
     .post(
       "/:providerID/oauth/authorize",
       "/:providerID/oauth/authorize",
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", ProviderAuth.AuthorizeInput.zod),
       validator("json", ProviderAuth.AuthorizeInput.zod),
-      async (c) => {
-        const providerID = c.req.valid("param").providerID
-        const { method, inputs } = c.req.valid("json")
-        const result = await AppRuntime.runPromise(
-          ProviderAuth.Service.use((svc) =>
-            svc.authorize({
-              providerID,
-              method,
-              inputs,
-            }),
-          ),
-        )
-        return c.json(result)
-      },
+      async (c) =>
+        jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
+          const providerID = c.req.valid("param").providerID
+          const { method, inputs } = c.req.valid("json")
+          const svc = yield* ProviderAuth.Service
+          return yield* svc.authorize({
+            providerID,
+            method,
+            inputs,
+          })
+        }),
     )
     )
     .post(
     .post(
       "/:providerID/oauth/callback",
       "/:providerID/oauth/callback",
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", ProviderAuth.CallbackInput.zod),
       validator("json", ProviderAuth.CallbackInput.zod),
-      async (c) => {
-        const providerID = c.req.valid("param").providerID
-        const { method, code } = c.req.valid("json")
-        await AppRuntime.runPromise(
-          ProviderAuth.Service.use((svc) =>
-            svc.callback({
-              providerID,
-              method,
-              code,
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
+          const providerID = c.req.valid("param").providerID
+          const { method, code } = c.req.valid("json")
+          const svc = yield* ProviderAuth.Service
+          yield* svc.callback({
+            providerID,
+            method,
+            code,
+          })
+          return true
+        }),
     ),
     ),
 )
 )

+ 29 - 40
packages/opencode/src/server/routes/instance/pty.ts

@@ -8,6 +8,7 @@ import { Pty } from "@/pty"
 import { PtyID } from "@/pty/schema"
 import { PtyID } from "@/pty/schema"
 import { NotFoundError } from "@/storage"
 import { NotFoundError } from "@/storage"
 import { errors } from "../../error"
 import { errors } from "../../error"
+import { jsonRequest, runRequest } from "./trace"
 
 
 export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
 export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
   return new Hono()
   return new Hono()
@@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        return c.json(
-          await AppRuntime.runPromise(
-            Effect.gen(function* () {
-              const pty = yield* Pty.Service
-              return yield* pty.list()
-            }),
-          ),
-        )
-      },
+      async (c) =>
+        jsonRequest("PtyRoutes.list", c, function* () {
+          const pty = yield* Pty.Service
+          return yield* pty.list()
+        }),
     )
     )
     .post(
     .post(
       "/",
       "/",
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         },
         },
       }),
       }),
       validator("json", Pty.CreateInput),
       validator("json", Pty.CreateInput),
-      async (c) => {
-        const info = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const pty = yield* Pty.Service
-            return yield* pty.create(c.req.valid("json"))
-          }),
-        )
-        return c.json(info)
-      },
+      async (c) =>
+        jsonRequest("PtyRoutes.create", c, function* () {
+          const pty = yield* Pty.Service
+          return yield* pty.create(c.req.valid("json"))
+        }),
     )
     )
     .get(
     .get(
       "/:ptyID",
       "/:ptyID",
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
       }),
       }),
       validator("param", z.object({ ptyID: PtyID.zod })),
       validator("param", z.object({ ptyID: PtyID.zod })),
       async (c) => {
       async (c) => {
-        const info = await AppRuntime.runPromise(
+        const info = await runRequest(
+          "PtyRoutes.get",
+          c,
           Effect.gen(function* () {
           Effect.gen(function* () {
             const pty = yield* Pty.Service
             const pty = yield* Pty.Service
             return yield* pty.get(c.req.valid("param").ptyID)
             return yield* pty.get(c.req.valid("param").ptyID)
@@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
       }),
       }),
       validator("param", z.object({ ptyID: PtyID.zod })),
       validator("param", z.object({ ptyID: PtyID.zod })),
       validator("json", Pty.UpdateInput),
       validator("json", Pty.UpdateInput),
-      async (c) => {
-        const info = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const pty = yield* Pty.Service
-            return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
-          }),
-        )
-        return c.json(info)
-      },
+      async (c) =>
+        jsonRequest("PtyRoutes.update", c, function* () {
+          const pty = yield* Pty.Service
+          return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
+        }),
     )
     )
     .delete(
     .delete(
       "/:ptyID",
       "/:ptyID",
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         },
         },
       }),
       }),
       validator("param", z.object({ ptyID: PtyID.zod })),
       validator("param", z.object({ ptyID: PtyID.zod })),
-      async (c) => {
-        await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const pty = yield* Pty.Service
-            yield* pty.remove(c.req.valid("param").ptyID)
-          }),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("PtyRoutes.remove", c, function* () {
+          const pty = yield* Pty.Service
+          yield* pty.remove(c.req.valid("param").ptyID)
+          return true
+        }),
     )
     )
     .get(
     .get(
       "/:ptyID/connect",
       "/:ptyID/connect",
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         })()
         })()
         let handler: Handler | undefined
         let handler: Handler | undefined
         if (
         if (
-          !(await AppRuntime.runPromise(
+          !(await runRequest(
+            "PtyRoutes.connect",
+            c,
             Effect.gen(function* () {
             Effect.gen(function* () {
               const pty = yield* Pty.Service
               const pty = yield* Pty.Service
               return yield* pty.get(id)
               return yield* pty.get(id)
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
               Effect.gen(function* () {
               Effect.gen(function* () {
                 const pty = yield* Pty.Service
                 const pty = yield* Pty.Service
                 return yield* pty.connect(id, socket, cursor)
                 return yield* pty.connect(id, socket, cursor)
-              }),
+              }).pipe(Effect.withSpan("PtyRoutes.connect.open")),
             )
             )
             ready = true
             ready = true
             for (const msg of pending) handler?.onMessage(msg)
             for (const msg of pending) handler?.onMessage(msg)

+ 24 - 23
packages/opencode/src/server/routes/instance/question.ts

@@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
 import { resolver } from "hono-openapi"
 import { resolver } from "hono-openapi"
 import { QuestionID } from "@/question/schema"
 import { QuestionID } from "@/question/schema"
 import { Question } from "@/question"
 import { Question } from "@/question"
-import { AppRuntime } from "@/effect/app-runtime"
 import z from "zod"
 import z from "zod"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 
 const Reply = z.object({
 const Reply = z.object({
   answers: Question.Answer.zod
   answers: Question.Answer.zod
@@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
           },
           },
         },
         },
       }),
       }),
-      async (c) => {
-        const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
-        return c.json(questions)
-      },
+      async (c) =>
+        jsonRequest("QuestionRoutes.list", c, function* () {
+          const svc = yield* Question.Service
+          return yield* svc.list()
+        }),
     )
     )
     .post(
     .post(
       "/:requestID/reply",
       "/:requestID/reply",
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", Reply),
       validator("json", Reply),
-      async (c) => {
-        const params = c.req.valid("param")
-        const json = c.req.valid("json")
-        await AppRuntime.runPromise(
-          Question.Service.use((svc) =>
-            svc.reply({
-              requestID: params.requestID,
-              answers: json.answers,
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("QuestionRoutes.reply", c, function* () {
+          const params = c.req.valid("param")
+          const json = c.req.valid("json")
+          const svc = yield* Question.Service
+          yield* svc.reply({
+            requestID: params.requestID,
+            answers: json.answers,
+          })
+          return true
+        }),
     )
     )
     .post(
     .post(
       "/:requestID/reject",
       "/:requestID/reject",
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
           requestID: QuestionID.zod,
           requestID: QuestionID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const params = c.req.valid("param")
-        await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("QuestionRoutes.reject", c, function* () {
+          const params = c.req.valid("param")
+          const svc = yield* Question.Service
+          yield* svc.reject(params.requestID)
+          return true
+        }),
     ),
     ),
 )
 )

+ 199 - 212
packages/opencode/src/server/routes/instance/session.ts

@@ -14,7 +14,6 @@ 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 { Effect } from "effect"
 import { Effect } from "effect"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Agent } from "@/agent/agent"
 import { Agent } from "@/agent/agent"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
 import { Command } from "@/command"
 import { Command } from "@/command"
@@ -26,7 +25,7 @@ import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { NamedError } from "@opencode-ai/shared/util/error"
-import { jsonRequest } from "./trace"
+import { jsonRequest, runRequest } from "./trace"
 
 
 const log = Log.create({ service: "server" })
 const log = Log.create({ service: "server" })
 
 
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
         },
         },
       }),
       }),
       validator("json", Session.CreateInput),
       validator("json", Session.CreateInput),
-      async (c) => {
-        const body = c.req.valid("json") ?? {}
-        const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
-        return c.json(session)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.create", c, function* () {
+          const body = c.req.valid("json") ?? {}
+          const svc = yield* SessionShare.Service
+          return yield* svc.create(body)
+        }),
     )
     )
     .delete(
     .delete(
       "/:sessionID",
       "/:sessionID",
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
           sessionID: Session.RemoveInput,
           sessionID: Session.RemoveInput,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.delete", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const svc = yield* Session.Service
+          yield* svc.remove(sessionID)
+          return true
+        }),
     )
     )
     .patch(
     .patch(
       "/:sessionID",
       "/:sessionID",
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
             .optional(),
             .optional(),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const updates = c.req.valid("json")
-        const session = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const session = yield* Session.Service
-            const current = yield* session.get(sessionID)
+      async (c) =>
+        jsonRequest("SessionRoutes.update", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const updates = c.req.valid("json")
+          const session = yield* Session.Service
+          const current = yield* session.get(sessionID)
 
 
-            if (updates.title !== undefined) {
-              yield* session.setTitle({ sessionID, title: updates.title })
-            }
-            if (updates.permission !== undefined) {
-              yield* session.setPermission({
-                sessionID,
-                permission: Permission.merge(current.permission ?? [], updates.permission),
-              })
-            }
-            if (updates.time?.archived !== undefined) {
-              yield* session.setArchived({ sessionID, time: updates.time.archived })
-            }
+          if (updates.title !== undefined) {
+            yield* session.setTitle({ sessionID, title: updates.title })
+          }
+          if (updates.permission !== undefined) {
+            yield* session.setPermission({
+              sessionID,
+              permission: Permission.merge(current.permission ?? [], updates.permission),
+            })
+          }
+          if (updates.time?.archived !== undefined) {
+            yield* session.setArchived({ sessionID, time: updates.time.archived })
+          }
 
 
-            return yield* session.get(sessionID)
-          }),
-        )
-        return c.json(session)
-      },
+          return yield* session.get(sessionID)
+        }),
     )
     )
     // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
     // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
     .post(
     .post(
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
           messageID: MessageID.zod,
           messageID: MessageID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const body = c.req.valid("json")
-        await AppRuntime.runPromise(
-          SessionPrompt.Service.use((svc) =>
-            svc.command({
-              sessionID,
-              messageID: body.messageID,
-              model: body.providerID + "/" + body.modelID,
-              command: Command.Default.INIT,
-              arguments: "",
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.init", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const body = c.req.valid("json")
+          const svc = yield* SessionPrompt.Service
+          yield* svc.command({
+            sessionID,
+            messageID: body.messageID,
+            model: body.providerID + "/" + body.modelID,
+            command: Command.Default.INIT,
+            arguments: "",
+          })
+          return true
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/fork",
       "/:sessionID/fork",
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", Session.ForkInput.omit({ sessionID: true })),
       validator("json", Session.ForkInput.omit({ sessionID: true })),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const body = c.req.valid("json")
-        const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
-        return c.json(result)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.fork", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const body = c.req.valid("json")
+          const svc = yield* Session.Service
+          return yield* svc.fork({ ...body, sessionID })
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/abort",
       "/:sessionID/abort",
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
           sessionID: SessionID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.abort", c, function* () {
+          const svc = yield* SessionPrompt.Service
+          yield* svc.cancel(c.req.valid("param").sessionID)
+          return true
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/share",
       "/:sessionID/share",
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
           sessionID: SessionID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const session = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const share = yield* SessionShare.Service
-            const session = yield* Session.Service
-            yield* share.share(sessionID)
-            return yield* session.get(sessionID)
-          }),
-        )
-        return c.json(session)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.share", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const share = yield* SessionShare.Service
+          const session = yield* Session.Service
+          yield* share.share(sessionID)
+          return yield* session.get(sessionID)
+        }),
     )
     )
     .get(
     .get(
       "/:sessionID/diff",
       "/:sessionID/diff",
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
           messageID: SessionSummary.DiffInput.shape.messageID,
           messageID: SessionSummary.DiffInput.shape.messageID,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const query = c.req.valid("query")
-        const params = c.req.valid("param")
-        const result = await AppRuntime.runPromise(
-          SessionSummary.Service.use((summary) =>
-            summary.diff({
-              sessionID: params.sessionID,
-              messageID: query.messageID,
-            }),
-          ),
-        )
-        return c.json(result)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.diff", c, function* () {
+          const query = c.req.valid("query")
+          const params = c.req.valid("param")
+          const summary = yield* SessionSummary.Service
+          return yield* summary.diff({
+            sessionID: params.sessionID,
+            messageID: query.messageID,
+          })
+        }),
     )
     )
     .delete(
     .delete(
       "/:sessionID/share",
       "/:sessionID/share",
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
           sessionID: SessionID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const session = await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const share = yield* SessionShare.Service
-            const session = yield* Session.Service
-            yield* share.unshare(sessionID)
-            return yield* session.get(sessionID)
-          }),
-        )
-        return c.json(session)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.unshare", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const share = yield* SessionShare.Service
+          const session = yield* Session.Service
+          yield* share.unshare(sessionID)
+          return yield* session.get(sessionID)
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/summarize",
       "/:sessionID/summarize",
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
           auto: z.boolean().optional().default(false),
           auto: z.boolean().optional().default(false),
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const body = c.req.valid("json")
-        await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const session = yield* Session.Service
-            const revert = yield* SessionRevert.Service
-            const compact = yield* SessionCompaction.Service
-            const prompt = yield* SessionPrompt.Service
-            const agent = yield* Agent.Service
+      async (c) =>
+        jsonRequest("SessionRoutes.summarize", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const body = c.req.valid("json")
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+          const compact = yield* SessionCompaction.Service
+          const prompt = yield* SessionPrompt.Service
+          const agent = yield* Agent.Service
 
 
-            yield* revert.cleanup(yield* session.get(sessionID))
-            const msgs = yield* session.messages({ sessionID })
-            const defaultAgent = yield* agent.defaultAgent()
-            let currentAgent = defaultAgent
-            for (let i = msgs.length - 1; i >= 0; i--) {
-              const info = msgs[i].info
-              if (info.role === "user") {
-                currentAgent = info.agent || defaultAgent
-                break
-              }
+          yield* revert.cleanup(yield* session.get(sessionID))
+          const msgs = yield* session.messages({ sessionID })
+          const defaultAgent = yield* agent.defaultAgent()
+          let currentAgent = defaultAgent
+          for (let i = msgs.length - 1; i >= 0; i--) {
+            const info = msgs[i].info
+            if (info.role === "user") {
+              currentAgent = info.agent || defaultAgent
+              break
             }
             }
+          }
 
 
-            yield* compact.create({
-              sessionID,
-              agent: currentAgent,
-              model: {
-                providerID: body.providerID,
-                modelID: body.modelID,
-              },
-              auto: body.auto,
-            })
-            yield* prompt.loop({ sessionID })
-          }),
-        )
-        return c.json(true)
-      },
+          yield* compact.create({
+            sessionID,
+            agent: currentAgent,
+            model: {
+              providerID: body.providerID,
+              modelID: body.modelID,
+            },
+            auto: body.auto,
+          })
+          yield* prompt.loop({ sessionID })
+          return true
+        }),
     )
     )
     .get(
     .get(
       "/:sessionID/message",
       "/:sessionID/message",
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
         const query = c.req.valid("query")
         const query = c.req.valid("query")
         const sessionID = c.req.valid("param").sessionID
         const sessionID = c.req.valid("param").sessionID
         if (query.limit === undefined || query.limit === 0) {
         if (query.limit === undefined || query.limit === 0) {
-          const messages = await AppRuntime.runPromise(
+          const messages = await runRequest(
+            "SessionRoutes.messages",
+            c,
             Effect.gen(function* () {
             Effect.gen(function* () {
               const session = yield* Session.Service
               const session = yield* Session.Service
               yield* session.get(sessionID)
               yield* session.get(sessionID)
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
           messageID: MessageID.zod,
           messageID: MessageID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const params = c.req.valid("param")
-        await AppRuntime.runPromise(
-          Effect.gen(function* () {
-            const state = yield* SessionRunState.Service
-            const session = yield* Session.Service
-            yield* state.assertNotBusy(params.sessionID)
-            yield* session.removeMessage({
-              sessionID: params.sessionID,
-              messageID: params.messageID,
-            })
-          }),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.deleteMessage", c, function* () {
+          const params = c.req.valid("param")
+          const state = yield* SessionRunState.Service
+          const session = yield* Session.Service
+          yield* state.assertNotBusy(params.sessionID)
+          yield* session.removeMessage({
+            sessionID: params.sessionID,
+            messageID: params.messageID,
+          })
+          return true
+        }),
     )
     )
     .delete(
     .delete(
       "/:sessionID/message/:messageID/part/:partID",
       "/:sessionID/message/:messageID/part/:partID",
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
           partID: PartID.zod,
           partID: PartID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const params = c.req.valid("param")
-        await AppRuntime.runPromise(
-          Session.Service.use((svc) =>
-            svc.removePart({
-              sessionID: params.sessionID,
-              messageID: params.messageID,
-              partID: params.partID,
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.deletePart", c, function* () {
+          const params = c.req.valid("param")
+          const svc = yield* Session.Service
+          yield* svc.removePart({
+            sessionID: params.sessionID,
+            messageID: params.messageID,
+            partID: params.partID,
+          })
+          return true
+        }),
     )
     )
     .patch(
     .patch(
       "/:sessionID/message/:messageID/part/:partID",
       "/:sessionID/message/:messageID/part/:partID",
@@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
             `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
             `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
           )
           )
         }
         }
-        const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
-        return c.json(part)
+        return jsonRequest("SessionRoutes.updatePart", c, function* () {
+          const svc = yield* Session.Service
+          return yield* svc.updatePart(body)
+        })
       },
       },
     )
     )
     .post(
     .post(
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
         return stream(c, async (stream) => {
         return stream(c, async (stream) => {
           const sessionID = c.req.valid("param").sessionID
           const sessionID = c.req.valid("param").sessionID
           const body = c.req.valid("json")
           const body = c.req.valid("json")
-          const msg = await AppRuntime.runPromise(
+          const msg = await runRequest(
+            "SessionRoutes.prompt",
+            c,
             SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
             SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
           )
           )
           void stream.write(JSON.stringify(msg))
           void stream.write(JSON.stringify(msg))
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
         const body = c.req.valid("json")
-        void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
-          (err) => {
-            log.error("prompt_async failed", { sessionID, error: err })
-            void Bus.publish(Session.Event.Error, {
-              sessionID,
-              error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
-            })
-          },
-        )
+        void runRequest(
+          "SessionRoutes.prompt_async",
+          c,
+          SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+        ).catch((err) => {
+          log.error("prompt_async failed", { sessionID, error: err })
+          void Bus.publish(Session.Event.Error, {
+            sessionID,
+            error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
+          })
+        })
 
 
         return c.body(null, 204)
         return c.body(null, 204)
       },
       },
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
       validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const body = c.req.valid("json")
-        const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
-        return c.json(msg)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.command", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const body = c.req.valid("json")
+          const svc = yield* SessionPrompt.Service
+          return yield* svc.command({ ...body, sessionID })
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/shell",
       "/:sessionID/shell",
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
       validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const body = c.req.valid("json")
-        const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
-        return c.json(msg)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.shell", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const body = c.req.valid("json")
+          const svc = yield* SessionPrompt.Service
+          return yield* svc.shell({ ...body, sessionID })
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/revert",
       "/:sessionID/revert",
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const sessionID = c.req.valid("param").sessionID
         log.info("revert", c.req.valid("json"))
         log.info("revert", c.req.valid("json"))
-        const session = await AppRuntime.runPromise(
-          SessionRevert.Service.use((svc) =>
-            svc.revert({
-              sessionID,
-              ...c.req.valid("json"),
-            }),
-          ),
-        )
-        return c.json(session)
+        return jsonRequest("SessionRoutes.revert", c, function* () {
+          const svc = yield* SessionRevert.Service
+          return yield* svc.revert({
+            sessionID,
+            ...c.req.valid("json"),
+          })
+        })
       },
       },
     )
     )
     .post(
     .post(
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
           sessionID: SessionID.zod,
           sessionID: SessionID.zod,
         }),
         }),
       ),
       ),
-      async (c) => {
-        const sessionID = c.req.valid("param").sessionID
-        const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
-        return c.json(session)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.unrevert", c, function* () {
+          const sessionID = c.req.valid("param").sessionID
+          const svc = yield* SessionRevert.Service
+          return yield* svc.unrevert({ sessionID })
+        }),
     )
     )
     .post(
     .post(
       "/:sessionID/permissions/:permissionID",
       "/:sessionID/permissions/:permissionID",
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
         }),
         }),
       ),
       ),
       validator("json", z.object({ response: Permission.Reply.zod })),
       validator("json", z.object({ response: Permission.Reply.zod })),
-      async (c) => {
-        const params = c.req.valid("param")
-        await AppRuntime.runPromise(
-          Permission.Service.use((svc) =>
-            svc.reply({
-              requestID: params.permissionID,
-              reply: c.req.valid("json").response,
-            }),
-          ),
-        )
-        return c.json(true)
-      },
+      async (c) =>
+        jsonRequest("SessionRoutes.permissionRespond", c, function* () {
+          const params = c.req.valid("param")
+          const svc = yield* Permission.Service
+          yield* svc.reply({
+            requestID: params.permissionID,
+            reply: c.req.valid("json").response,
+          })
+          return true
+        }),
     ),
     ),
 )
 )

+ 37 - 11
packages/opencode/src/server/routes/instance/trace.ts

@@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
 
 
 type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
 type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
 
 
+// Build the base span attributes for an HTTP handler: method, path, and every
+// matched route param. Names follow OTel attribute-naming guidance:
+// domain-first (`session.id`, `message.id`, …) so they match the existing
+// OTel `session.id` semantic convention and the bare `message.id` we
+// already emit from Tool.execute. Non-standard route params fall back to
+// `opencode.<name>` since those are internal implementation details
+// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
+export interface RequestLike {
+  readonly req: {
+    readonly method: string
+    readonly url: string
+    param(): Record<string, string>
+  }
+}
+
+// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
+// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
+// other param is namespaced under `opencode.` to avoid colliding with
+// standard conventions.
+export function paramToAttributeKey(key: string): string {
+  const m = key.match(/^(.+)ID$/)
+  if (m) return `${m[1].toLowerCase()}.id`
+  return `opencode.${key}`
+}
+
+export function requestAttributes(c: RequestLike): Record<string, string> {
+  const attributes: Record<string, string> = {
+    "http.method": c.req.method,
+    "http.path": new URL(c.req.url).pathname,
+  }
+  for (const [key, value] of Object.entries(c.req.param())) {
+    attributes[paramToAttributeKey(key)] = value
+  }
+  return attributes
+}
+
 export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
 export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
-  const url = new URL(c.req.url)
-  return AppRuntime.runPromise(
-    effect.pipe(
-      Effect.withSpan(name, {
-        attributes: {
-          "http.method": c.req.method,
-          "http.path": url.pathname,
-        },
-      }),
-    ),
-  )
+  return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
 }
 }
 
 
 export async function jsonRequest<C extends Context, A, E>(
 export async function jsonRequest<C extends Context, A, E>(

+ 6 - 2
packages/opencode/src/server/routes/instance/tui.ts

@@ -4,10 +4,10 @@ import z from "zod"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { Session } from "@/session"
 import { Session } from "@/session"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import { TuiEvent } from "@/cli/cmd/tui/event"
-import { AppRuntime } from "@/effect/app-runtime"
 import { AsyncQueue } from "@/util/queue"
 import { AsyncQueue } from "@/util/queue"
 import { errors } from "../../error"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { lazy } from "@/util/lazy"
+import { runRequest } from "./trace"
 
 
 const TuiRequest = z.object({
 const TuiRequest = z.object({
   path: z.string(),
   path: z.string(),
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
       validator("json", TuiEvent.SessionSelect.properties),
       validator("json", TuiEvent.SessionSelect.properties),
       async (c) => {
       async (c) => {
         const { sessionID } = c.req.valid("json")
         const { sessionID } = c.req.valid("json")
-        await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
+        await runRequest(
+          "TuiRoutes.sessionSelect",
+          c,
+          Session.Service.use((svc) => svc.get(sessionID)),
+        )
         await Bus.publish(TuiEvent.SessionSelect, { sessionID })
         await Bus.publish(TuiEvent.SessionSelect, { sessionID })
         return c.json(true)
         return c.json(true)
       },
       },

+ 11 - 8
packages/opencode/src/server/server.ts

@@ -14,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control"
 import { UIRoutes } from "./routes/ui"
 import { UIRoutes } from "./routes/ui"
 import { GlobalRoutes } from "./routes/global"
 import { GlobalRoutes } from "./routes/global"
 import { WorkspaceRouterMiddleware } from "./workspace"
 import { WorkspaceRouterMiddleware } from "./workspace"
+import { InstanceMiddleware } from "./routes/instance/middleware"
+import { WorkspaceRoutes } from "./routes/control/workspace"
 
 
 // @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
@@ -45,14 +47,9 @@ function create(opts: { cors?: string[] }) {
   if (Flag.OPENCODE_WORKSPACE_ID) {
   if (Flag.OPENCODE_WORKSPACE_ID) {
     return {
     return {
       app: app
       app: app
+        .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
         .use(FenceMiddleware)
         .use(FenceMiddleware)
-        .route(
-          "/",
-          InstanceRoutes(
-            runtime.upgradeWebSocket,
-            Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined,
-          ),
-        ),
+        .route("/", InstanceRoutes(runtime.upgradeWebSocket)),
       runtime,
       runtime,
     }
     }
   }
   }
@@ -60,7 +57,13 @@ function create(opts: { cors?: string[] }) {
   return {
   return {
     app: app
     app: app
       .route("/", ControlPlaneRoutes())
       .route("/", ControlPlaneRoutes())
-      .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
+      .route(
+        "/",
+        new Hono()
+          .use(InstanceMiddleware())
+          .route("/experimental/workspace", WorkspaceRoutes())
+          .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
+      )
       .route("/", InstanceRoutes(runtime.upgradeWebSocket))
       .route("/", InstanceRoutes(runtime.upgradeWebSocket))
       .route("/", UIRoutes()),
       .route("/", UIRoutes()),
     runtime,
     runtime,

+ 4 - 1
packages/opencode/src/server/workspace.ts

@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
 import { Session } from "@/session"
 import { Session } from "@/session"
 import { SessionID } from "@/session/schema"
 import { SessionID } from "@/session/schema"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AppRuntime } from "@/effect/app-runtime"
+import { Effect } from "effect"
 import { Log } from "@/util"
 import { Log } from "@/util"
 import { ServerProxy } from "./proxy"
 import { ServerProxy } from "./proxy"
 
 
@@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) {
   const id = getSessionID(url)
   const id = getSessionID(url)
   if (!id) return null
   if (!id) return null
 
 
-  const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
+  const session = await AppRuntime.runPromise(
+    Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
+  ).catch(() => undefined)
   return session?.workspaceID
   return session?.workspaceID
 }
 }
 
 

+ 33 - 8
packages/opencode/src/share/share-next.ts

@@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
 export type Share = typeof ShareSchema.Type
 export type Share = typeof ShareSchema.Type
 
 
 type State = {
 type State = {
-  queue: Map<string, { data: Map<string, Data> }>
+  queue: Map<SessionID, Map<string, Data>>
   scope: Scope.Closeable
   scope: Scope.Closeable
+  shared: Map<SessionID, Share | null>
 }
 }
 
 
 type Data =
 type Data =
@@ -118,17 +119,20 @@ export const layer = Layer.effect(
     function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
     function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
       return Effect.gen(function* () {
       return Effect.gen(function* () {
         if (disabled) return
         if (disabled) return
+        const share = yield* getCached(sessionID)
+        if (!share) return
+
         const s = yield* InstanceState.get(state)
         const s = yield* InstanceState.get(state)
         const existing = s.queue.get(sessionID)
         const existing = s.queue.get(sessionID)
         if (existing) {
         if (existing) {
           for (const item of data) {
           for (const item of data) {
-            existing.data.set(key(item), item)
+            existing.set(key(item), item)
           }
           }
           return
           return
         }
         }
 
 
         const next = new Map(data.map((item) => [key(item), item]))
         const next = new Map(data.map((item) => [key(item), item]))
-        s.queue.set(sessionID, { data: next })
+        s.queue.set(sessionID, next)
         yield* flush(sessionID).pipe(
         yield* flush(sessionID).pipe(
           Effect.delay(1000),
           Effect.delay(1000),
           Effect.catchCause((cause) =>
           Effect.catchCause((cause) =>
@@ -143,13 +147,14 @@ export const layer = Layer.effect(
 
 
     const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
     const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
       Effect.fn("ShareNext.state")(function* (_ctx) {
       Effect.fn("ShareNext.state")(function* (_ctx) {
-        const cache: State = { queue: new Map(), scope: yield* Scope.make() }
+        const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() }
 
 
         yield* Effect.addFinalizer(() =>
         yield* Effect.addFinalizer(() =>
           Scope.close(cache.scope, Exit.void).pipe(
           Scope.close(cache.scope, Exit.void).pipe(
             Effect.andThen(
             Effect.andThen(
               Effect.sync(() => {
               Effect.sync(() => {
                 cache.queue.clear()
                 cache.queue.clear()
+                cache.shared.clear()
               }),
               }),
             ),
             ),
           ),
           ),
@@ -227,6 +232,18 @@ export const layer = Layer.effect(
       return { id: row.id, secret: row.secret, url: row.url } satisfies Share
       return { id: row.id, secret: row.secret, url: row.url } satisfies Share
     })
     })
 
 
+    const getCached = Effect.fnUntraced(function* (sessionID: SessionID) {
+      const s = yield* InstanceState.get(state)
+      if (s.shared.has(sessionID)) {
+        const cached = s.shared.get(sessionID)
+        return cached === null ? undefined : cached
+      }
+
+      const share = yield* get(sessionID)
+      s.shared.set(sessionID, share ?? null)
+      return share
+    })
+
     const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
     const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
       if (disabled) return
       if (disabled) return
       const s = yield* InstanceState.get(state)
       const s = yield* InstanceState.get(state)
@@ -235,13 +252,13 @@ export const layer = Layer.effect(
 
 
       s.queue.delete(sessionID)
       s.queue.delete(sessionID)
 
 
-      const share = yield* get(sessionID)
+      const share = yield* getCached(sessionID)
       if (!share) return
       if (!share) return
 
 
       const req = yield* request()
       const req = yield* request()
       const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
       const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
         HttpClientRequest.setHeaders(req.headers),
         HttpClientRequest.setHeaders(req.headers),
-        HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }),
+        HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }),
         Effect.flatMap((r) => http.execute(r)),
         Effect.flatMap((r) => http.execute(r)),
       )
       )
 
 
@@ -307,6 +324,7 @@ export const layer = Layer.effect(
           .run(),
           .run(),
       )
       )
       const s = yield* InstanceState.get(state)
       const s = yield* InstanceState.get(state)
+      s.shared.set(sessionID, result)
       yield* full(sessionID).pipe(
       yield* full(sessionID).pipe(
         Effect.catchCause((cause) =>
         Effect.catchCause((cause) =>
           Effect.sync(() => {
           Effect.sync(() => {
@@ -321,8 +339,13 @@ export const layer = Layer.effect(
     const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
     const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
       if (disabled) return
       if (disabled) return
       log.info("removing share", { sessionID })
       log.info("removing share", { sessionID })
-      const share = yield* get(sessionID)
-      if (!share) return
+      const s = yield* InstanceState.get(state)
+      const share = yield* getCached(sessionID)
+      if (!share) {
+        s.shared.delete(sessionID)
+        s.queue.delete(sessionID)
+        return
+      }
 
 
       const req = yield* request()
       const req = yield* request()
       yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
       yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
@@ -332,6 +355,8 @@ export const layer = Layer.effect(
       )
       )
 
 
       yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
       yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
+      s.shared.delete(sessionID)
+      s.queue.delete(sessionID)
     })
     })
 
 
     return Service.of({ init, url, request, create, remove })
     return Service.of({ init, url, request, create, remove })

+ 106 - 9
packages/opencode/src/util/effect-zod.ts

@@ -1,4 +1,4 @@
-import { Schema, SchemaAST } from "effect"
+import { Effect, Option, Schema, SchemaAST } from "effect"
 import z from "zod"
 import z from "zod"
 
 
 /**
 /**
@@ -8,19 +8,97 @@ import z from "zod"
  */
  */
 export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
 export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
 
 
+// AST nodes are immutable and frequently shared across schemas (e.g. a single
+// Schema.Class embedded in multiple parents). Memoizing by node identity
+// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
+// by reference across callers.
+const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
+
+// Shared empty ParseOptions for the rare callers that need one — avoids
+// allocating a fresh object per parse inside refinements and transforms.
+const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
+
 export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
 export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
 }
 }
 
 
 function walk(ast: SchemaAST.AST): z.ZodTypeAny {
 function walk(ast: SchemaAST.AST): z.ZodTypeAny {
+  const cached = walkCache.get(ast)
+  if (cached) return cached
+  const result = walkUncached(ast)
+  walkCache.set(ast, result)
+  return result
+}
+
+function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
   const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
   const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
   if (override) return override
   if (override) return override
 
 
-  const out = body(ast)
+  // Schema.Class wraps its fields in a Declaration AST plus an encoding that
+  // constructs the class instance. For the Zod derivation we want the plain
+  // field shape (the decoded/consumer view), not the class instance — so
+  // Declarations fall through to body(), not encoded(). User-level
+  // Schema.decodeTo / Schema.transform attach encoding to non-Declaration
+  // nodes, where we do apply the transform.
+  const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
+  const base = hasTransform ? encoded(ast) : body(ast)
+  const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
   const desc = SchemaAST.resolveDescription(ast)
   const desc = SchemaAST.resolveDescription(ast)
   const ref = SchemaAST.resolveIdentifier(ast)
   const ref = SchemaAST.resolveIdentifier(ast)
-  const next = desc ? out.describe(desc) : out
-  return ref ? next.meta({ ref }) : next
+  const described = desc ? out.describe(desc) : out
+  return ref ? described.meta({ ref }) : described
+}
+
+// Walk the encoded side and apply each link's decode to produce the decoded
+// shape. A node `Target` produced by `from.decodeTo(Target)` carries
+// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
+// nest the encoding via `Link.to` so walking it recursively threads all
+// prior transforms — typical encoding.length is 1.
+function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
+  const encoding = ast.encoding!
+  return encoding.reduce<z.ZodTypeAny>(
+    (acc, link) => acc.transform((v) => decode(link.transformation, v)),
+    walk(encoding[0].to),
+  )
+}
+
+// Transformations built via pure `SchemaGetter.transform(fn)` (the common
+// decodeTo case) resolve synchronously, so running with no services is safe.
+// Effectful / middleware-based transforms will surface as Effect defects.
+function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
+  const exit = Effect.runSyncExit(
+    (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
+      Option.Option<unknown>
+    >,
+  )
+  if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
+  return Option.getOrElse(exit.value, () => value)
+}
+
+// Flatten FilterGroups and any nested variants into a linear list of Filters
+// so we can run all of them inside a single Zod .superRefine wrapper instead
+// of stacking N wrapper layers (one per check).
+function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
+  const filters: SchemaAST.Filter<unknown>[] = []
+  const collect = (c: SchemaAST.Check<unknown>) => {
+    if (c._tag === "FilterGroup") c.checks.forEach(collect)
+    else filters.push(c)
+  }
+  checks.forEach(collect)
+  return out.superRefine((value, ctx) => {
+    for (const filter of filters) {
+      const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
+      if (!issue) continue
+      const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
+      ctx.addIssue({ code: "custom", message })
+    }
+  })
+}
+
+function issueMessage(issue: any): string | undefined {
+  if (typeof issue?.annotations?.message === "string") return issue.annotations.message
+  if (typeof issue?.message === "string") return issue.message
+  return undefined
 }
 }
 
 
 function body(ast: SchemaAST.AST): z.ZodTypeAny {
 function body(ast: SchemaAST.AST): z.ZodTypeAny {
@@ -86,21 +164,40 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
 }
 }
 
 
 function object(ast: SchemaAST.Objects): z.ZodTypeAny {
 function object(ast: SchemaAST.Objects): z.ZodTypeAny {
+  // Pure record: { [k: string]: V }
   if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
   if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
     const sig = ast.indexSignatures[0]
     const sig = ast.indexSignatures[0]
     if (sig.parameter._tag !== "String") return fail(ast)
     if (sig.parameter._tag !== "String") return fail(ast)
     return z.record(z.string(), walk(sig.type))
     return z.record(z.string(), walk(sig.type))
   }
   }
 
 
-  if (ast.indexSignatures.length > 0) return fail(ast)
+  // Pure object with known fields and no index signatures.
+  if (ast.indexSignatures.length === 0) {
+    return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
+  }
 
 
-  return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
+  // Struct with a catchall (StructWithRest): known fields + index signature.
+  // Only supports a single string-keyed index signature; multi-signature or
+  // symbol/number keys fall through to fail.
+  if (ast.indexSignatures.length !== 1) return fail(ast)
+  const sig = ast.indexSignatures[0]
+  if (sig.parameter._tag !== "String") return fail(ast)
+  return z
+    .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
+    .catchall(walk(sig.type))
 }
 }
 
 
 function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
 function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
-  if (ast.elements.length > 0) return fail(ast)
-  if (ast.rest.length !== 1) return fail(ast)
-  return z.array(walk(ast.rest[0]))
+  // Pure variadic arrays: { elements: [], rest: [item] }
+  if (ast.elements.length === 0) {
+    if (ast.rest.length !== 1) return fail(ast)
+    return z.array(walk(ast.rest[0]))
+  }
+  // Fixed-length tuples: { elements: [a, b, ...], rest: [] }
+  // Tuples with a variadic tail (...rest) are not yet supported.
+  if (ast.rest.length > 0) return fail(ast)
+  const items = ast.elements.map(walk)
+  return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
 }
 }
 
 
 function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
 function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {

+ 80 - 79
packages/opencode/src/v2/session-entry.ts

@@ -1,6 +1,6 @@
 import { Schema } from "effect"
 import { Schema } from "effect"
 import { SessionEvent } from "./session-event"
 import { SessionEvent } from "./session-event"
-import { produce } from "immer"
+import { castDraft, produce } from "immer"
 
 
 export const ID = SessionEvent.ID
 export const ID = SessionEvent.ID
 export type ID = Schema.Schema.Type<typeof ID>
 export type ID = Schema.Schema.Type<typeof ID>
@@ -70,7 +70,9 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.
   metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
   metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
 }) {}
 }) {}
 
 
-export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
+export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
+  Schema.toTaggedUnion("status"),
+)
 export type ToolState = Schema.Schema.Type<typeof ToolState>
 export type ToolState = Schema.Schema.Type<typeof ToolState>
 
 
 export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
 export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
@@ -96,7 +98,9 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
   text: Schema.String,
   text: Schema.String,
 }) {}
 }) {}
 
 
-export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
+export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+  Schema.toTaggedUnion("type"),
+)
 export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
 export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
 
 
 export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
 export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
@@ -126,7 +130,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compacti
   ...Base,
   ...Base,
 }) {}
 }) {}
 
 
-export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
+export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
 
 
 export type Entry = Schema.Schema.Type<typeof Entry>
 export type Entry = Schema.Schema.Type<typeof Entry>
 
 
@@ -141,19 +145,29 @@ export function step(old: History, event: SessionEvent.Event): History {
   return produce(old, (draft) => {
   return produce(old, (draft) => {
     const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
     const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
     const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
     const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
+    type DraftContent = NonNullable<typeof pendingAssistant>["content"][number]
+    type DraftTool = Extract<DraftContent, { type: "tool" }>
+
+    const latestTool = (callID?: string) =>
+      pendingAssistant?.content.findLast(
+        (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
+      )
+    const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text")
+    const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning")
 
 
-    switch (event.type) {
-      case "prompt": {
+    SessionEvent.Event.match(event, {
+      prompt: (event) => {
+        const entry = User.fromEvent(event)
         if (pendingAssistant) {
         if (pendingAssistant) {
-          // @ts-expect-error
-          draft.pending.push(User.fromEvent(event))
-          break
+          draft.pending.push(castDraft(entry))
+          return
         }
         }
-        // @ts-expect-error
-        draft.entries.push(User.fromEvent(event))
-        break
-      }
-      case "step.started": {
+        draft.entries.push(castDraft(entry))
+      },
+      synthetic: (event) => {
+        draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } }))
+      },
+      "step.started": (event) => {
         if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
         if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
         draft.entries.push({
         draft.entries.push({
           id: event.id,
           id: event.id,
@@ -163,27 +177,28 @@ export function step(old: History, event: SessionEvent.Event): History {
           },
           },
           content: [],
           content: [],
         })
         })
-        break
-      }
-      case "text.started": {
-        if (!pendingAssistant) break
+      },
+      "step.ended": (event) => {
+        if (!pendingAssistant) return
+        pendingAssistant.time.completed = event.timestamp
+        pendingAssistant.cost = event.cost
+        pendingAssistant.tokens = event.tokens
+      },
+      "text.started": () => {
+        if (!pendingAssistant) return
         pendingAssistant.content.push({
         pendingAssistant.content.push({
           type: "text",
           type: "text",
           text: "",
           text: "",
         })
         })
-        break
-      }
-      case "text.delta": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "text")
+      },
+      "text.delta": (event) => {
+        if (!pendingAssistant) return
+        const match = latestText()
         if (match) match.text += event.delta
         if (match) match.text += event.delta
-        break
-      }
-      case "text.ended": {
-        break
-      }
-      case "tool.input.started": {
-        if (!pendingAssistant) break
+      },
+      "text.ended": () => {},
+      "tool.input.started": (event) => {
+        if (!pendingAssistant) return
         pendingAssistant.content.push({
         pendingAssistant.content.push({
           type: "tool",
           type: "tool",
           callID: event.callID,
           callID: event.callID,
@@ -196,21 +211,17 @@ export function step(old: History, event: SessionEvent.Event): History {
             input: "",
             input: "",
           },
           },
         })
         })
-        break
-      }
-      case "tool.input.delta": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "tool")
+      },
+      "tool.input.delta": (event) => {
+        if (!pendingAssistant) return
+        const match = latestTool(event.callID)
         // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
         // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
         if (match) match.state.input += event.delta
         if (match) match.state.input += event.delta
-        break
-      }
-      case "tool.input.ended": {
-        break
-      }
-      case "tool.called": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "tool")
+      },
+      "tool.input.ended": () => {},
+      "tool.called": (event) => {
+        if (!pendingAssistant) return
+        const match = latestTool(event.callID)
         if (match) {
         if (match) {
           match.time.ran = event.timestamp
           match.time.ran = event.timestamp
           match.state = {
           match.state = {
@@ -218,11 +229,10 @@ export function step(old: History, event: SessionEvent.Event): History {
             input: event.input,
             input: event.input,
           }
           }
         }
         }
-        break
-      }
-      case "tool.success": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "tool")
+      },
+      "tool.success": (event) => {
+        if (!pendingAssistant) return
+        const match = latestTool(event.callID)
         if (match && match.state.status === "running") {
         if (match && match.state.status === "running") {
           match.state = {
           match.state = {
             status: "completed",
             status: "completed",
@@ -230,15 +240,13 @@ export function step(old: History, event: SessionEvent.Event): History {
             output: event.output ?? "",
             output: event.output ?? "",
             title: event.title,
             title: event.title,
             metadata: event.metadata ?? {},
             metadata: event.metadata ?? {},
-            // @ts-expect-error
-            attachments: event.attachments ?? [],
+            attachments: [...(event.attachments ?? [])],
           }
           }
         }
         }
-        break
-      }
-      case "tool.error": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "tool")
+      },
+      "tool.error": (event) => {
+        if (!pendingAssistant) return
+        const match = latestTool(event.callID)
         if (match && match.state.status === "running") {
         if (match && match.state.status === "running") {
           match.state = {
           match.state = {
             status: "error",
             status: "error",
@@ -247,36 +255,29 @@ export function step(old: History, event: SessionEvent.Event): History {
             metadata: event.metadata ?? {},
             metadata: event.metadata ?? {},
           }
           }
         }
         }
-        break
-      }
-      case "reasoning.started": {
-        if (!pendingAssistant) break
+      },
+      "reasoning.started": () => {
+        if (!pendingAssistant) return
         pendingAssistant.content.push({
         pendingAssistant.content.push({
           type: "reasoning",
           type: "reasoning",
           text: "",
           text: "",
         })
         })
-        break
-      }
-      case "reasoning.delta": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
+      },
+      "reasoning.delta": (event) => {
+        if (!pendingAssistant) return
+        const match = latestReasoning()
         if (match) match.text += event.delta
         if (match) match.text += event.delta
-        break
-      }
-      case "reasoning.ended": {
-        if (!pendingAssistant) break
-        const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
+      },
+      "reasoning.ended": (event) => {
+        if (!pendingAssistant) return
+        const match = latestReasoning()
         if (match) match.text = event.text
         if (match) match.text = event.text
-        break
-      }
-      case "step.ended": {
-        if (!pendingAssistant) break
-        pendingAssistant.time.completed = event.timestamp
-        pendingAssistant.cost = event.cost
-        pendingAssistant.tokens = event.tokens
-        break
-      }
-    }
+      },
+      retried: () => {},
+      compacted: (event) => {
+        draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } }))
+      },
+    })
   })
   })
 }
 }
 
 

+ 1 - 1
packages/opencode/src/v2/session-event.ts

@@ -441,7 +441,7 @@ export namespace SessionEvent {
     {
     {
       mode: "oneOf",
       mode: "oneOf",
     },
     },
-  )
+  ).pipe(Schema.toTaggedUnion("type"))
   export type Event = Schema.Schema.Type<typeof Event>
   export type Event = Schema.Schema.Type<typeof Event>
   export type Type = Event["type"]
   export type Type = Event["type"]
 }
 }

+ 87 - 0
packages/opencode/test/config/lsp.test.ts

@@ -0,0 +1,87 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+import { ConfigLSP } from "../../src/config/lsp"
+
+// The LSP config refinement enforces: any custom (non-builtin) LSP server
+// entry must declare an `extensions` array so the client knows which files
+// the server should attach to. Builtin server IDs and explicitly disabled
+// entries are exempt.
+//
+// Both validation paths must honor this rule:
+//   - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
+//   - `ConfigLSP.Info.zod.parse(...)`            (derived Zod)
+//
+// `typescript` is a builtin server id (see src/lsp/server.ts).
+describe("ConfigLSP.Info refinement", () => {
+  const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
+
+  describe("accepted inputs", () => {
+    test("true and false pass (top-level toggle)", () => {
+      expect(decodeEffect(true)).toBe(true)
+      expect(decodeEffect(false)).toBe(false)
+      expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
+      expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
+    })
+
+    test("builtin server with no extensions passes", () => {
+      const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
+      expect(decodeEffect(input)).toEqual(input)
+      expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+    })
+
+    test("custom server WITH extensions passes", () => {
+      const input = {
+        "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
+      }
+      expect(decodeEffect(input)).toEqual(input)
+      expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+    })
+
+    test("disabled custom server passes (no extensions needed)", () => {
+      const input = { "my-lsp": { disabled: true as const } }
+      expect(decodeEffect(input)).toEqual(input)
+      expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+    })
+
+    test("mix of builtin and custom with extensions passes", () => {
+      const input = {
+        typescript: { command: ["typescript-language-server", "--stdio"] },
+        "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
+      }
+      expect(decodeEffect(input)).toEqual(input)
+      expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+    })
+  })
+
+  describe("rejected inputs", () => {
+    const expectedMessage = "For custom LSP servers, 'extensions' array is required."
+
+    test("custom server WITHOUT extensions fails via Effect decode", () => {
+      expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
+    })
+
+    test("custom server WITHOUT extensions fails via derived Zod", () => {
+      const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
+      expect(result.success).toBe(false)
+      expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
+    })
+
+    test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
+      // Boolean(['']) is true, so a non-empty array of strings is fine.
+      // Boolean([]) is also true in JS, so empty arrays are accepted by the
+      // refinement. This test documents current behavior.
+      const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
+      expect(decodeEffect(input)).toEqual(input)
+      expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+    })
+
+    test("custom server without extensions mixed with a valid builtin still fails", () => {
+      const input = {
+        typescript: { command: ["typescript-language-server", "--stdio"] },
+        "my-lsp": { command: ["my-lsp-bin"] },
+      }
+      expect(() => decodeEffect(input)).toThrow(expectedMessage)
+      expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
+    })
+  })
+})

+ 76 - 0
packages/opencode/test/server/trace-attributes.test.ts

@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
+
+function fakeContext(method: string, url: string, params: Record<string, string>) {
+  return {
+    req: {
+      method,
+      url,
+      param: () => params,
+    },
+  }
+}
+
+describe("paramToAttributeKey", () => {
+  test("converts fooID to foo.id", () => {
+    expect(paramToAttributeKey("sessionID")).toBe("session.id")
+    expect(paramToAttributeKey("messageID")).toBe("message.id")
+    expect(paramToAttributeKey("partID")).toBe("part.id")
+    expect(paramToAttributeKey("projectID")).toBe("project.id")
+    expect(paramToAttributeKey("providerID")).toBe("provider.id")
+    expect(paramToAttributeKey("ptyID")).toBe("pty.id")
+    expect(paramToAttributeKey("permissionID")).toBe("permission.id")
+    expect(paramToAttributeKey("requestID")).toBe("request.id")
+    expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
+  })
+
+  test("namespaces non-ID params under opencode.", () => {
+    expect(paramToAttributeKey("name")).toBe("opencode.name")
+    expect(paramToAttributeKey("slug")).toBe("opencode.slug")
+  })
+})
+
+describe("requestAttributes", () => {
+  test("includes http method and path", () => {
+    const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
+    expect(attrs["http.method"]).toBe("GET")
+    expect(attrs["http.path"]).toBe("/session")
+  })
+
+  test("strips query string from path", () => {
+    const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
+    expect(attrs["http.path"]).toBe("/file/search")
+  })
+
+  test("emits OTel-style <domain>.id for ID-shaped route params", () => {
+    const attrs = requestAttributes(
+      fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
+        sessionID: "ses_abc",
+        messageID: "msg_def",
+        partID: "prt_ghi",
+      }),
+    )
+    expect(attrs["session.id"]).toBe("ses_abc")
+    expect(attrs["message.id"]).toBe("msg_def")
+    expect(attrs["part.id"]).toBe("prt_ghi")
+    // No camelCase leftovers:
+    expect(attrs["opencode.sessionID"]).toBeUndefined()
+    expect(attrs["opencode.messageID"]).toBeUndefined()
+    expect(attrs["opencode.partID"]).toBeUndefined()
+  })
+
+  test("produces no param attributes when no params are matched", () => {
+    const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
+    expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
+  })
+
+  test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
+    const attrs = requestAttributes(
+      fakeContext("POST", "http://localhost/mcp/exa/connect", {
+        name: "exa",
+      }),
+    )
+    expect(attrs["opencode.name"]).toBe("exa")
+    expect(attrs["name"]).toBeUndefined()
+  })
+})

+ 59 - 2
packages/opencode/test/session/session-entry.test.ts

@@ -591,7 +591,64 @@ describe("session-entry step", () => {
       )
       )
     })
     })
 
 
-    test.failing("records synthetic events", () => {
+    test("routes tool events by callID when tool streams interleave", () => {
+      FastCheck.assert(
+        FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
+          const next = run(
+            [
+              SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
+              SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
+              SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
+              SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
+              SessionEvent.Tool.Called.create({
+                callID: "a",
+                tool: "bash",
+                input: a,
+                provider: { executed: true },
+                timestamp: time(5),
+              }),
+              SessionEvent.Tool.Called.create({
+                callID: "b",
+                tool: "grep",
+                input: b,
+                provider: { executed: true },
+                timestamp: time(6),
+              }),
+              SessionEvent.Tool.Success.create({
+                callID: "a",
+                title: titleA,
+                output: "done-a",
+                provider: { executed: true },
+                timestamp: time(7),
+              }),
+              SessionEvent.Tool.Success.create({
+                callID: "b",
+                title: titleB,
+                output: "done-b",
+                provider: { executed: true },
+                timestamp: time(8),
+              }),
+            ],
+            active(),
+          )
+
+          const first = tool(next, "a")
+          const second = tool(next, "b")
+
+          expect(first?.state.status).toBe("completed")
+          expect(second?.state.status).toBe("completed")
+          if (first?.state.status !== "completed" || second?.state.status !== "completed") return
+
+          expect(first.state.input).toEqual(a)
+          expect(second.state.input).toEqual(b)
+          expect(first.state.title).toBe(titleA)
+          expect(second.state.title).toBe(titleB)
+        }),
+        { numRuns: 50 },
+      )
+    })
+
+    test("records synthetic events", () => {
       FastCheck.assert(
       FastCheck.assert(
         FastCheck.property(word, (body) => {
         FastCheck.property(word, (body) => {
           const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
           const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
@@ -604,7 +661,7 @@ describe("session-entry step", () => {
       )
       )
     })
     })
 
 
-    test.failing("records compaction events", () => {
+    test("records compaction events", () => {
       FastCheck.assert(
       FastCheck.assert(
         FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
         FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
           const next = SessionEntry.step(
           const next = SessionEntry.step(

+ 295 - 3
packages/opencode/test/util/effect-zod.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
-import { Schema } from "effect"
+import { Schema, SchemaGetter } from "effect"
 import z from "zod"
 import z from "zod"
 
 
 import { zod, ZodOverride } from "../../src/util/effect-zod"
 import { zod, ZodOverride } from "../../src/util/effect-zod"
@@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
     })
     })
   })
   })
 
 
-  test("throws for unsupported tuple schemas", () => {
-    expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
+  describe("Tuples", () => {
+    test("fixed-length tuple parses matching array", () => {
+      const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
+      expect(out.parse(["a", 1])).toEqual(["a", 1])
+      expect(out.safeParse(["a"]).success).toBe(false)
+      expect(out.safeParse(["a", "b"]).success).toBe(false)
+    })
+
+    test("single-element tuple parses a one-element array", () => {
+      const out = zod(Schema.Tuple([Schema.Boolean]))
+      expect(out.parse([true])).toEqual([true])
+      expect(out.safeParse([true, false]).success).toBe(false)
+    })
+
+    test("tuple inside a union picks the right branch", () => {
+      const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
+      expect(out.parse("hello")).toBe("hello")
+      expect(out.parse(["foo", 42])).toEqual(["foo", 42])
+      expect(out.safeParse(["foo"]).success).toBe(false)
+    })
+
+    test("plain arrays still work (no element positions)", () => {
+      const out = zod(Schema.Array(Schema.String))
+      expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
+      expect(out.parse([])).toEqual([])
+    })
   })
   })
 
 
   test("string literal unions produce z.enum with enum in JSON Schema", () => {
   test("string literal unions produce z.enum with enum in JSON Schema", () => {
@@ -186,4 +210,272 @@ describe("util.effect-zod", () => {
     const schema = json(zod(Parent)) as any
     const schema = json(zod(Parent)) as any
     expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
     expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
   })
   })
+
+  describe("Schema.check translation", () => {
+    test("filter returning string triggers refinement with that message", () => {
+      const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
+      const schema = zod(Schema.Number.check(isEven))
+
+      expect(schema.parse(4)).toBe(4)
+      const result = schema.safeParse(3)
+      expect(result.success).toBe(false)
+      expect(result.error!.issues[0].message).toBe("expected an even number")
+    })
+
+    test("filter returning false triggers refinement with fallback message", () => {
+      const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
+      const schema = zod(Schema.String.check(nonEmpty))
+
+      expect(schema.parse("hi")).toBe("hi")
+      const result = schema.safeParse("")
+      expect(result.success).toBe(false)
+      expect(result.error!.issues[0].message).toMatch(/./)
+    })
+
+    test("filter returning undefined passes validation", () => {
+      const alwaysOk = Schema.makeFilter(() => undefined)
+      const schema = zod(Schema.Number.check(alwaysOk))
+
+      expect(schema.parse(42)).toBe(42)
+    })
+
+    test("annotations.message on the filter is used when filter returns false", () => {
+      const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
+      const schema = zod(Schema.Number.check(positive))
+
+      const result = schema.safeParse(-1)
+      expect(result.success).toBe(false)
+      expect(result.error!.issues[0].message).toBe("must be positive")
+    })
+
+    test("cross-field check on a record flags missing key", () => {
+      const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
+        "required" in data ? undefined : "missing 'required' key",
+      )
+      const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
+
+      expect(schema.parse({ required: { enabled: true } })).toEqual({
+        required: { enabled: true },
+      })
+
+      const result = schema.safeParse({ other: { enabled: true } })
+      expect(result.success).toBe(false)
+      expect(result.error!.issues[0].message).toBe("missing 'required' key")
+    })
+  })
+
+  describe("StructWithRest / catchall", () => {
+    test("struct with a string-keyed record rest parses known AND extra keys", () => {
+      const schema = zod(
+        Schema.StructWithRest(
+          Schema.Struct({
+            apiKey: Schema.optional(Schema.String),
+            baseURL: Schema.optional(Schema.String),
+          }),
+          [Schema.Record(Schema.String, Schema.Unknown)],
+        ),
+      )
+
+      // Known fields come through as declared
+      expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
+
+      // Extra keys are preserved (catchall)
+      expect(
+        schema.parse({
+          apiKey: "sk-x",
+          baseURL: "https://api.example.com",
+          customField: "anything",
+          nested: { foo: 1 },
+        }),
+      ).toEqual({
+        apiKey: "sk-x",
+        baseURL: "https://api.example.com",
+        customField: "anything",
+        nested: { foo: 1 },
+      })
+    })
+
+    test("catchall value type constrains the extras", () => {
+      const schema = zod(
+        Schema.StructWithRest(
+          Schema.Struct({
+            count: Schema.Number,
+          }),
+          [Schema.Record(Schema.String, Schema.Number)],
+        ),
+      )
+
+      // Known field + numeric extras
+      expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
+
+      // Non-numeric extra is rejected
+      expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
+    })
+
+    test("JSON schema output marks additionalProperties appropriately", () => {
+      const schema = zod(
+        Schema.StructWithRest(
+          Schema.Struct({
+            id: Schema.String,
+          }),
+          [Schema.Record(Schema.String, Schema.Unknown)],
+        ),
+      )
+      const shape = json(schema) as { additionalProperties?: unknown }
+      // Presence of `additionalProperties` (truthy or a schema) signals catchall.
+      expect(shape.additionalProperties).not.toBe(false)
+      expect(shape.additionalProperties).toBeDefined()
+    })
+
+    test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
+      const schema = zod(Schema.Struct({ id: Schema.String }))
+      expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
+    })
+  })
+
+  describe("transforms (Schema.decodeTo)", () => {
+    test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
+      // Models the account/account.ts DurationFromSeconds pattern.
+      const SecondsToMs = Schema.Number.pipe(
+        Schema.decodeTo(Schema.Number, {
+          decode: SchemaGetter.transform((n: number) => n * 1000),
+          encode: SchemaGetter.transform((ms: number) => ms / 1000),
+        }),
+      )
+
+      const schema = zod(SecondsToMs)
+      expect(schema.parse(3)).toBe(3000)
+      expect(schema.parse(0)).toBe(0)
+    })
+
+    test("String -> Number via parseInt decode", () => {
+      const ParsedInt = Schema.String.pipe(
+        Schema.decodeTo(Schema.Number, {
+          decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
+          encode: SchemaGetter.transform((n: number) => String(n)),
+        }),
+      )
+
+      const schema = zod(ParsedInt)
+      expect(schema.parse("42")).toBe(42)
+      expect(schema.parse("0")).toBe(0)
+    })
+
+    test("transform inside a struct field applies per-field", () => {
+      const Field = Schema.Number.pipe(
+        Schema.decodeTo(Schema.Number, {
+          decode: SchemaGetter.transform((n: number) => n + 1),
+          encode: SchemaGetter.transform((n: number) => n - 1),
+        }),
+      )
+
+      const schema = zod(
+        Schema.Struct({
+          plain: Schema.Number,
+          bumped: Field,
+        }),
+      )
+
+      expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
+    })
+
+    test("chained decodeTo composes transforms in order", () => {
+      // String -> Number (parseInt) -> Number (doubled).
+      // Exercises the encoded() reduce, not just a single link.
+      const Chained = Schema.String.pipe(
+        Schema.decodeTo(Schema.Number, {
+          decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
+          encode: SchemaGetter.transform((n: number) => String(n)),
+        }),
+        Schema.decodeTo(Schema.Number, {
+          decode: SchemaGetter.transform((n: number) => n * 2),
+          encode: SchemaGetter.transform((n: number) => n / 2),
+        }),
+      )
+
+      const schema = zod(Chained)
+      expect(schema.parse("21")).toBe(42)
+      expect(schema.parse("0")).toBe(0)
+    })
+
+    test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
+      // Schema.Class uses Declaration + encoding under the hood to construct
+      // class instances. The walker must NOT apply that transform, or zod
+      // parsing would return class instances instead of plain objects.
+      class Method extends Schema.Class<Method>("TxTestMethod")({
+        type: Schema.String,
+        value: Schema.Number,
+      }) {}
+
+      const schema = zod(Method)
+      const parsed = schema.parse({ type: "oauth", value: 1 })
+      expect(parsed).toEqual({ type: "oauth", value: 1 })
+      // Guardrail: ensure we didn't get back a Method instance.
+      expect(parsed).not.toBeInstanceOf(Method)
+    })
+  })
+
+  describe("optimizations", () => {
+    test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
+      const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
+      const left = zod(shared)
+      const right = zod(shared)
+      expect(left).toBe(right)
+    })
+
+    test("nested reuse of the same AST reuses the cached Zod child", () => {
+      // Two different parents embed the same inner schema. The inner zod
+      // child should be identical by reference inside both parents.
+      class Inner extends Schema.Class<Inner>("MemoTestInner")({
+        value: Schema.String,
+      }) {}
+
+      class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
+        inner: Inner,
+      }) {}
+
+      class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
+        inner: Inner,
+      }) {}
+
+      const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
+      const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
+      expect(shapeA.inner).toBe(shapeB.inner)
+    })
+
+    test("multiple checks run in a single refinement layer (all fire on one value)", () => {
+      // Three checks attached to the same schema. All three must run and
+      // report — asserting that no check silently got dropped when we
+      // flattened into one superRefine.
+      const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
+      const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
+      const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
+
+      const schema = zod(Schema.Number.check(positive).check(even).check(under100))
+
+      const neg = schema.safeParse(-3)
+      expect(neg.success).toBe(false)
+      expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
+
+      const big = schema.safeParse(101)
+      expect(big.success).toBe(false)
+      expect(big.error!.issues.map((i) => i.message)).toContain("too big")
+
+      // Passing value satisfies all three
+      expect(schema.parse(42)).toBe(42)
+    })
+
+    test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
+      const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
+      const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
+      const group = Schema.makeFilterGroup([positive, even])
+      const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
+
+      const schema = zod(Schema.Number.check(group).check(under100))
+
+      const bad = schema.safeParse(-3)
+      expect(bad.success).toBe(false)
+      expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
+    })
+  })
 })
 })

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/plugin",
   "name": "@opencode-ai/plugin",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/sdk/js/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/sdk",
   "name": "@opencode-ai/sdk",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 7 - 5
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1807,6 +1807,12 @@ export type Provider = {
   }
   }
 }
 }
 
 
+export type ConsoleState = {
+  consoleManagedProviders: Array<string>
+  activeOrgName?: string
+  switchableOrgCount: number
+}
+
 export type ToolIds = Array<string>
 export type ToolIds = Array<string>
 
 
 export type ToolListItem = {
 export type ToolListItem = {
@@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
   /**
   /**
    * Active Console provider metadata
    * Active Console provider metadata
    */
    */
-  200: {
-    consoleManagedProviders: Array<string>
-    activeOrgName?: string
-    switchableOrgCount: number
-  }
+  200: ConsoleState
 }
 }
 
 
 export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
 export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]

+ 26 - 36
packages/sdk/openapi.json

@@ -1607,24 +1607,7 @@
             "content": {
             "content": {
               "application/json": {
               "application/json": {
                 "schema": {
                 "schema": {
-                  "type": "object",
-                  "properties": {
-                    "consoleManagedProviders": {
-                      "type": "array",
-                      "items": {
-                        "type": "string"
-                      }
-                    },
-                    "activeOrgName": {
-                      "type": "string"
-                    },
-                    "switchableOrgCount": {
-                      "type": "integer",
-                      "minimum": 0,
-                      "maximum": 9007199254740991
-                    }
-                  },
-                  "required": ["consoleManagedProviders", "switchableOrgCount"]
+                  "$ref": "#/components/schemas/ConsoleState"
                 }
                 }
               }
               }
             }
             }
@@ -11197,13 +11180,11 @@
                 "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
                 "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
                 "anyOf": [
                 "anyOf": [
                   {
                   {
-                    "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
                     "type": "integer",
                     "type": "integer",
                     "exclusiveMinimum": 0,
                     "exclusiveMinimum": 0,
                     "maximum": 9007199254740991
                     "maximum": 9007199254740991
                   },
                   },
                   {
                   {
-                    "description": "Disable timeout for this provider entirely.",
                     "type": "boolean",
                     "type": "boolean",
                     "const": false
                     "const": false
                   }
                   }
@@ -11264,8 +11245,7 @@
                           "enum": ["reasoning_content", "reasoning_details"]
                           "enum": ["reasoning_content", "reasoning_details"]
                         }
                         }
                       },
                       },
-                      "required": ["field"],
-                      "additionalProperties": false
+                      "required": ["field"]
                     }
                     }
                   ]
                   ]
                 },
                 },
@@ -11394,8 +11374,7 @@
               }
               }
             }
             }
           }
           }
-        },
-        "additionalProperties": false
+        }
       },
       },
       "McpLocalConfig": {
       "McpLocalConfig": {
         "type": "object",
         "type": "object",
@@ -11428,13 +11407,10 @@
           },
           },
           "timeout": {
           "timeout": {
             "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.",
-            "type": "integer",
-            "exclusiveMinimum": 0,
-            "maximum": 9007199254740991
+            "type": "number"
           }
           }
         },
         },
-        "required": ["type", "command"],
-        "additionalProperties": false
+        "required": ["type", "command"]
       },
       },
       "McpOAuthConfig": {
       "McpOAuthConfig": {
         "type": "object",
         "type": "object",
@@ -11455,8 +11431,7 @@
             "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
             "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
             "type": "string"
             "type": "string"
           }
           }
-        },
-        "additionalProperties": false
+        }
       },
       },
       "McpRemoteConfig": {
       "McpRemoteConfig": {
         "type": "object",
         "type": "object",
@@ -11498,13 +11473,10 @@
           },
           },
           "timeout": {
           "timeout": {
             "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.",
-            "type": "integer",
-            "exclusiveMinimum": 0,
-            "maximum": 9007199254740991
+            "type": "number"
           }
           }
         },
         },
-        "required": ["type", "url"],
-        "additionalProperties": false
+        "required": ["type", "url"]
       },
       },
       "LayoutConfig": {
       "LayoutConfig": {
         "description": "@deprecated Always uses stretch layout.",
         "description": "@deprecated Always uses stretch layout.",
@@ -12366,6 +12338,24 @@
         },
         },
         "required": ["id", "name", "source", "env", "options", "models"]
         "required": ["id", "name", "source", "env", "options", "models"]
       },
       },
+      "ConsoleState": {
+        "type": "object",
+        "properties": {
+          "consoleManagedProviders": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "activeOrgName": {
+            "type": "string"
+          },
+          "switchableOrgCount": {
+            "type": "number"
+          }
+        },
+        "required": ["consoleManagedProviders", "switchableOrgCount"]
+      },
       "ToolIDs": {
       "ToolIDs": {
         "type": "array",
         "type": "array",
         "items": {
         "items": {

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "name": "@opencode-ai/shared",
   "name": "@opencode-ai/shared",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/slack",
   "name": "@opencode-ai/slack",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/ui",
   "name": "@opencode-ai/ui",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "exports": {
   "exports": {

+ 1 - 1
packages/web/package.json

@@ -2,7 +2,7 @@
   "name": "@opencode-ai/web",
   "name": "@opencode-ai/web",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "scripts": {
   "scripts": {
     "dev": "astro dev",
     "dev": "astro dev",
     "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
     "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

+ 1 - 1
sdks/vscode/package.json

@@ -2,7 +2,7 @@
   "name": "opencode",
   "name": "opencode",
   "displayName": "opencode",
   "displayName": "opencode",
   "description": "opencode for VS Code",
   "description": "opencode for VS Code",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "publisher": "sst-dev",
   "publisher": "sst-dev",
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",