Selaa lähdekoodia

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

Kit Langton 1 päivä sitten
vanhempi
sitoutus
2d1ee3a2ac
72 muutettua tiedostoa jossa 2005 lisäystä ja 1299 poistoa
  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": {
       "name": "@opencode-ai/app",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
     },
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/shared": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -458,7 +458,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "effect": "catalog:",
@@ -493,7 +493,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -508,7 +508,7 @@
     },
     "packages/shared": {
       "name": "@opencode-ai/shared",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -532,7 +532,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.4.9",
+      "version": "1.4.11",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",

+ 1 - 1
packages/app/package.json

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
packages/desktop/package.json

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

+ 1 - 1
packages/enterprise/package.json

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

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

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

+ 1 - 1
packages/function/package.json

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

+ 1 - 1
packages/opencode/package.json

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

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

@@ -1,12 +1,13 @@
 # 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:
 
@@ -15,8 +16,9 @@ Recent progress:
 
 ## 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
 
@@ -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.
 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
 
-- [ ] `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
 

+ 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
 
-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
 
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
 
 ### 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
 - 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:
 
-- `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.
 
@@ -155,9 +155,9 @@ This gives:
 
 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
 
 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:
 
-- 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:
 
@@ -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.
 4. Define the `HttpApi` contract separately from the handlers.
 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).
 8. Add one end-to-end test and one OpenAPI-focused test.
 9. Compare ergonomics before migrating the next endpoint.
@@ -250,20 +286,20 @@ Placement rule:
 - keep `HttpApi` code under `src/server`, not `src/effect`
 - `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
-- 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:
 
-- `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:
 
 - `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
 
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
 - keep handler bodies thin
 - 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
 
 - 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 generated OpenAPI contains the migrated paths and schemas
 
 ## 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
 
-- 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
-- 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
 
-- 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`
 - `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 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
 - request decoding and response shapes are schema-defined from canonical Effect schemas
 - 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`
 - `permission` - `bridged`
   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`
   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
-- `workspace` - `later`
+- `workspace` - `next`
   best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
   defer create/remove mutations first
 - `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
 - `tui` - `defer`
   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
 
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
 - [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
 - [x] verify OTEL spans and HTTP logs flow to motel
 - [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
 
 ## 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.dispose()` / `Instance.disposeAll()`
 
-Current total: `54` files in `packages/opencode/src`.
+Current total: `56` files in `packages/opencode/src`.
 
 ### 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.
 
-- `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:
 
@@ -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.
 
 - `src/agent/agent.ts`
-- `src/config/tui-migrate.ts`
+- `src/cli/cmd/tui/config/tui-migrate.ts`
 - `src/file/index.ts`
 - `src/file/watcher.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/provider/provider.ts`
 - `src/pty/index.ts`
-- `src/session/index.ts`
+- `src/session/session.ts`
 - `src/session/instruction.ts`
 - `src/session/llm.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.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(...)`.
-- [ ] `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`.
-- [ ] `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
 
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
   - `readFile(...)`
   - `parseText(...)`
 - [ ] `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
 
-- [ ] `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
 

+ 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
 
-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
-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:
 
-- 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
 
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
 
 ## 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.
 
@@ -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.
 - `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.
-- `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.
 - `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.
 
 ## 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 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
 
-- 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:
 
 - 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`
 - 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`
-- 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.
 
@@ -209,17 +209,19 @@ Current host and route composition:
 
 - `src/server/server.ts`
 - `src/server/control/index.ts`
-- `src/server/instance/index.ts`
+- `src/server/routes/instance/index.ts`
 - `src/server/middleware.ts`
 - `src/server/adapter.bun.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:
 
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
 
 - `src/server/server.ts`
 - `src/server/control/index.ts`
-- `src/server/instance/*.ts`
+- `src/server/routes/**/*.ts`
 - `src/server/middleware.ts`
 - `src/server/adapter.*.ts`
 - `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
 
 ## 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:
 
 - it already exists as an experimental `HttpApi` slice
 - it already follows the desired contract and implementation split in one file
 - it is already mounted through the current Hono host
-- it already has an end-to-end test
 - it is JSON-only
 - it has low blast radius
 
@@ -357,7 +358,7 @@ Done means:
 
 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`
 - aggregate it in `packages/server/src/definition/api.ts`
 - generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
 
 - replace local experimental question route wiring in `packages/opencode`
 - keep the same mount path:
-- `/experimental/httpapi/question`
-- `/experimental/httpapi/question/doc`
+- `/question`
+- `/question/:requestID/reply`
+- `/question/:requestID/reject`
 
 Rules:
 
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
 Typical validation for the first waves:
 
 - `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
 
 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
 
-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] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
 - [x] `glob.ts`
 - [x] `grep.ts`
 - [x] `invalid.ts`
-- [x] `ls.ts`
 - [x] `lsp.ts`
 - [x] `multiedit.ts`
 - [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
 
 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.
 - `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
 - [ ] `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
-- [ ] `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
 
 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`
 - `codesearch.ts`
 - `websearch.ts`
-- `ls.ts`
 - `multiedit.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(),
     theme: z.string().optional(),
     keybinds: KeybindOverride.optional(),
-    plugin: ConfigPlugin.Spec.array().optional(),
+    plugin: ConfigPlugin.Spec.zod.array().optional(),
     plugin_enabled: z.record(z.string(), z.boolean()).optional(),
   })
   .extend(TuiOptions.shape)

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

@@ -158,7 +158,12 @@ export const layer = Layer.effect(
       (dir) =>
         npm
           .install(dir, {
-            add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+            add: [
+              {
+                name: "@opencode-ai/plugin",
+                version: InstallationLocal ? undefined : InstallationVersion,
+              },
+            ],
           })
           .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 { useTheme } from "../../context/theme"
 import { useTuiConfig } from "../../context/tui-config"
-import { InstallationVersion } from "@/installation/version"
+import { InstallationChannel, InstallationVersion } from "@/installation/version"
 import { TuiPluginRuntime } from "../../plugin"
 
 import { getScrollAcceleration } from "../../util/scroll"
@@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 <text fg={theme.text}>
                   <b>{session()!.title}</b>
                 </text>
+                <Show when={InstallationChannel !== "latest"}>
+                  <text fg={theme.textMuted}>{props.sessionID}</text>
+                </Show>
                 <Show when={session()!.workspaceID}>
                   <text fg={theme.textMuted}>
                     <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
   .object({
-    model: ConfigModelID.optional(),
+    model: ConfigModelID.zod.optional(),
     variant: z
       .string()
       .optional()

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

@@ -1,10 +1,12 @@
 export * as ConfigCommand from "./command"
 
 import { Log } from "../util"
-import z from "zod"
+import { Schema } from "effect"
 import { NamedError } from "@opencode-ai/shared/util/error"
 import { Glob } from "@opencode-ai/shared/util/glob"
 import { Bus } from "@/bus"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import { configEntryNameFromPath } from "./entry-name"
 import { InvalidError } from "./error"
 import * as ConfigMarkdown from "./markdown"
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
 
 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) {
   const result: Record<string, Info> = {}
@@ -49,7 +51,7 @@ export async function load(dir: string) {
       ...md.data,
       template: md.content.trim(),
     }
-    const parsed = Info.safeParse(config)
+    const parsed = Info.zod.safeParse(config)
     if (parsed.success) {
       result[config.name] = parsed.data
       continue

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

@@ -97,10 +97,10 @@ export const Info = z
     logLevel: Log.Level.optional().describe("Log level"),
     server: Server.optional().describe("Server configuration for opencode serve and web commands"),
     command: z
-      .record(z.string(), ConfigCommand.Info)
+      .record(z.string(), ConfigCommand.Info.zod)
       .optional()
       .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
       .object({
         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.",
       ),
     // 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
       .enum(["manual", "auto", "disabled"])
       .optional()
@@ -135,10 +135,10 @@ export const Info = z
       .array(z.string())
       .optional()
       .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
       .string()
       .optional()
@@ -171,14 +171,14 @@ export const Info = z
       .optional()
       .describe("Agent configuration, see https://opencode.ai/docs/agents"),
     provider: z
-      .record(z.string(), ConfigProvider.Info)
+      .record(z.string(), ConfigProvider.Info.zod)
       .optional()
       .describe("Custom provider configurations and model overrides"),
     mcp: z
       .record(
         z.string(),
         z.union([
-          ConfigMCP.Info,
+          ConfigMCP.Info.zod,
           z
             .object({
               enabled: z.boolean(),
@@ -188,8 +188,8 @@ export const Info = z
       )
       .optional()
       .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"),
     layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
     permission: ConfigPermission.Info.optional(),
@@ -518,7 +518,12 @@ export const layer = Layer.effect(
 
           const dep = yield* npmSvc
             .install(dir, {
-              add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+              add: [
+                {
+                  name: "@opencode-ai/plugin",
+                  version: InstallationLocal ? undefined : InstallationVersion,
+                },
+              ],
             })
             .pipe(
               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: [],
   activeOrgName: undefined,
   switchableOrgCount: 0,
-}
+})

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

@@ -1,13 +1,17 @@
 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"
 
-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"
 
-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,
-  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"

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

@@ -1,3 +1,14 @@
+import { Schema } from "effect"
 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"
+import { Schema } from "effect"
 import z from "zod"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 const permissionPreprocess = (val: unknown) => {
   if (typeof val === "object" && val !== null && !Array.isArray(val)) {
@@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => {
   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> => {
   if (typeof x === "string") return { "*": x as Action }
@@ -41,25 +44,25 @@ export const Info = z
     z
       .object({
         __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)
   .meta({

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

@@ -1,16 +1,20 @@
 import { Glob } from "@opencode-ai/shared/util/glob"
-import z from "zod"
+import { Schema } from "effect"
 import { pathToFileURL } from "url"
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 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.
 // 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"
 

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

@@ -1,120 +1,118 @@
+import { Schema } from "effect"
 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.",
-          ),
-        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.",
-          ),
-      })
-      .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"

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

+ 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 install: (
     dir: string,
-    input?: { add: string[] },
+    input?: {
+      add: {
+        name: string
+        version?: string
+      }[]
+    },
   ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
   readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
   readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
@@ -137,17 +142,18 @@ export const layer = Layer.effect(
       return resolveEntryPoint(first.name, first.path)
     }, 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(
         Effect.as(true),
         Effect.orElseSucceed(() => false),
       )
       if (!canWrite) return
 
+      const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
       yield* Effect.gen(function* () {
         const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
         if (!nodeModulesExists) {
-          yield* reify({ add: input?.add, dir })
+          yield* reify({ add, dir })
           return
         }
       }).pipe(Effect.withSpan("Npm.checkNodeModules"))
@@ -163,7 +169,7 @@ export const layer = Layer.effect(
           ...Object.keys(pkgAny?.devDependencies || {}),
           ...Object.keys(pkgAny?.peerDependencies || {}),
           ...Object.keys(pkgAny?.optionalDependencies || {}),
-          ...(input?.add || []),
+          ...(input?.add || []).map((pkg) => pkg.name),
         ])
 
         const root = lockAny?.packages?.[""] || {}
@@ -176,7 +182,7 @@ export const layer = Layer.effect(
 
         for (const name of declared) {
           if (!locked.has(name)) {
-            yield* reify({ dir, add: input?.add })
+            yield* reify({ dir, add })
             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 { Model } from "@opencode-ai/sdk/v2"
-import { Installation } from "@/installation"
 import { InstallationVersion } from "@/installation/version"
 import { iife } from "@/util/iife"
 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`
       version: z.string(),
       supported_endpoints: z.array(z.string()).optional(),
+      policy: z
+        .object({
+          state: z.string().optional(),
+        })
+        .optional(),
       capabilities: z.object({
         family: z.string(),
         limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
   })
 
   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
   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"
 
 export namespace PluginLoader {
+  // A normalized plugin declaration derived from config before any filesystem or npm work happens.
   export type Plan = {
     spec: string
     options: ConfigPlugin.Options | undefined
     deprecated: boolean
   }
+
+  // A plugin that has been resolved to a concrete target and entrypoint on disk.
   export type Resolved = Plan & {
     source: PluginSource
     target: string
     entry: string
     pkg?: PluginPackage
   }
+
+  // A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
   export type Missing = Plan & {
     source: PluginSource
     target: string
     pkg?: PluginPackage
     message: string
   }
+
+  // A resolved plugin whose module has been imported successfully.
   export type Loaded = Resolved & {
     mod: Record<string, unknown>
   }
 
   type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
   type Report = {
+    // Called before each attempt so callers can log initial load attempts and retries uniformly.
     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
+    // Called for operational failures such as install, compatibility, or dynamic import errors.
     error?: (
       candidate: Candidate,
       retry: boolean,
@@ -46,11 +56,16 @@ export namespace PluginLoader {
     ) => void
   }
 
+  // Normalize a config item into the loader's internal representation.
   function plan(item: ConfigPlugin.Spec): Plan {
     const spec = ConfigPlugin.pluginSpecifier(item)
     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(
     plan: Plan,
     kind: PluginKind,
@@ -59,6 +74,7 @@ export namespace PluginLoader {
     | { ok: false; stage: "missing"; value: Missing }
     | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
   > {
+    // First make sure the plugin exists locally, installing npm plugins on demand.
     let target = ""
     try {
       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`) }
 
+    // Then inspect the target for the requested server/tui entrypoint.
     let base
     try {
       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") {
       try {
         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 } }
   }
 
+  // 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 }> {
     let mod
     try {
@@ -107,6 +127,8 @@ export namespace PluginLoader {
     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>(
     candidate: Candidate,
     kind: PluginKind,
@@ -116,11 +138,17 @@ export namespace PluginLoader {
     report: Report | undefined,
   ): Promise<R | undefined> {
     const plan = candidate.plan
+
+    // Deprecated plugin packages are silently ignored because they are now built in.
     if (plan.deprecated) return
+
     report?.start?.(candidate, retry)
+
     const resolved = await resolve(plan, kind)
     if (!resolved.ok) {
       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) {
           const value = await missing(resolved.value, candidate.origin, retry)
           if (value !== undefined) return value
@@ -131,11 +159,15 @@ export namespace PluginLoader {
       report?.error?.(candidate, retry, resolved.stage, resolved.error)
       return
     }
+
     const loaded = await load(resolved.value)
     if (!loaded.ok) {
       report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
       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
     return finish(loaded.value, candidate.origin, retry)
   }
@@ -149,6 +181,11 @@ export namespace PluginLoader {
     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[]> {
     const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
     const list: Array<Promise<R | undefined>> = []
@@ -160,6 +197,9 @@ export namespace PluginLoader {
       let deps: Promise<void> | undefined
       for (let i = 0; i < candidates.length; i++) {
         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]
         if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
         deps ??= input.wait()
@@ -167,6 +207,8 @@ export namespace PluginLoader {
         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[] = []
     for (const item of out) if (item !== undefined) ready.push(item)
     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 z from "zod"
 import { errors } from "../../error"
-import { WorkspaceRoutes } from "./workspace"
 
 export function ControlPlaneRoutes(): Hono {
   const app = new Hono()
@@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
         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 { errors } from "../../error"
 import { lazy } from "@/util/lazy"
-import { AppRuntime } from "@/effect/app-runtime"
 import { jsonRequest } from "./trace"
 
 export const ConfigRoutes = lazy(() =>
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
         },
       }),
       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(
       "/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 { Account } from "@/account/account"
 import { AccountID, OrgID } from "@/account/schema"
-import { AppRuntime } from "@/effect/app-runtime"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { Effect, Option } from "effect"
 import { Agent } from "@/agent/agent"
+import { jsonRequest, runRequest } from "./trace"
 
 const ConsoleOrgOption = z.object({
   accountID: z.string(),
@@ -49,28 +49,24 @@ export const ExperimentalRoutes = lazy(() =>
             description: "Active Console provider metadata",
             content: {
               "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(
       "/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(
       "/console/switch",
@@ -130,16 +123,13 @@ export const ExperimentalRoutes = lazy(() =>
         },
       }),
       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(
       "/tool/ids",
@@ -160,15 +150,11 @@ export const ExperimentalRoutes = lazy(() =>
           ...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(
       "/tool",
@@ -210,7 +196,9 @@ export const ExperimentalRoutes = lazy(() =>
       ),
       async (c) => {
         const { provider, model } = c.req.valid("query")
-        const tools = await AppRuntime.runPromise(
+        const tools = await runRequest(
+          "ExperimentalRoutes.tool.list",
+          c,
           Effect.gen(function* () {
             const agents = yield* Agent.Service
             const registry = yield* ToolRegistry.Service
@@ -249,11 +237,12 @@ export const ExperimentalRoutes = lazy(() =>
         },
       }),
       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(
       "/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(
       "/worktree",
@@ -296,14 +286,15 @@ export const ExperimentalRoutes = lazy(() =>
         },
       }),
       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(
       "/worktree/reset",
@@ -324,11 +315,13 @@ export const ExperimentalRoutes = lazy(() =>
         },
       }),
       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(
       "/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 { describeRoute, validator, resolver } from "hono-openapi"
-import { Effect } from "effect"
 import z from "zod"
-import { AppRuntime } from "@/effect/app-runtime"
 import { File } from "@/file"
 import { Ripgrep } from "@/file/ripgrep"
 import { LSP } from "@/lsp"
 import { Instance } from "@/project/instance"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 export const FileRoutes = lazy(() =>
   new Hono()
@@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
           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(
       "/find/file",
@@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
           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(
       "/find/symbol",
@@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
           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(
       "/file/content",
@@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
           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(
       "/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 { PermissionRoutes } from "./permission"
 import { Flag } from "@/flag/flag"
-import { WorkspaceID } from "@/control-plane/schema"
 import { ExperimentalHttpApiServer } from "./httpapi/server"
 import { ProjectRoutes } from "./project"
 import { SessionRoutes } from "./session"
@@ -27,11 +26,11 @@ import { ExperimentalRoutes } from "./experimental"
 import { ProviderRoutes } from "./provider"
 import { EventRoutes } from "./event"
 import { SyncRoutes } from "./sync"
-import { AppRuntime } from "@/effect/app-runtime"
 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) {
     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(
       "/vcs/diff",
@@ -179,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac
           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(
       "/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(
       "/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(
       "/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(
       "/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(
       "/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 z from "zod"
 import { MCP } from "@/mcp"
-import { Config } from "@/config"
 import { ConfigMCP } from "@/config/mcp"
-import { AppRuntime } from "@/effect/app-runtime"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { Effect } from "effect"
+import { jsonRequest, runRequest } from "./trace"
 
 export const McpRoutes = lazy(() =>
   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(
       "/",
@@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
         "json",
         z.object({
           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(
       "/:name/auth",
@@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
       }),
       async (c) => {
         const name = c.req.param("name")
-        const result = await AppRuntime.runPromise(
+        const result = await runRequest(
+          "McpRoutes.auth.start",
+          c,
           Effect.gen(function* () {
             const mcp = yield* MCP.Service
             const supports = yield* mcp.supportsOAuth(name)
@@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
           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(
       "/:name/auth/authenticate",
@@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
       }),
       async (c) => {
         const name = c.req.param("name")
-        const result = await AppRuntime.runPromise(
+        const result = await runRequest(
+          "McpRoutes.auth.authenticate",
+          c,
           Effect.gen(function* () {
             const mcp = yield* MCP.Service
             const supports = yield* mcp.supportsOAuth(name)
@@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
           ...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(
       "/:name/connect",
@@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
         },
       }),
       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(
       "/:name/disconnect",
@@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
         },
       }),
       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 { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 export const PermissionRoutes = lazy(() =>
   new Hono()
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
         }),
       ),
       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(
       "/",
@@ -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 { InstanceBootstrap } from "@/project/bootstrap"
 import { AppRuntime } from "@/effect/app-runtime"
+import { jsonRequest, runRequest } from "./trace"
 
 export const ProjectRoutes = lazy(() =>
   new Hono()
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
       async (c) => {
         const dir = Instance.directory
         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 })),
         )
         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("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 { ProviderAuth } from "@/provider"
 import { ProviderID } from "@/provider/schema"
-import { AppRuntime } from "@/effect/app-runtime"
 import { mapValues } from "remeda"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { Effect } from "effect"
+import { jsonRequest } from "./trace"
 
 export const ProviderRoutes = lazy(() =>
   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(
       "/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(
       "/:providerID/oauth/authorize",
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
         }),
       ),
       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(
       "/:providerID/oauth/callback",
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
         }),
       ),
       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 { NotFoundError } from "@/storage"
 import { errors } from "../../error"
+import { jsonRequest, runRequest } from "./trace"
 
 export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
   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(
       "/",
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         },
       }),
       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(
       "/:ptyID",
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
       }),
       validator("param", z.object({ ptyID: PtyID.zod })),
       async (c) => {
-        const info = await AppRuntime.runPromise(
+        const info = await runRequest(
+          "PtyRoutes.get",
+          c,
           Effect.gen(function* () {
             const pty = yield* Pty.Service
             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("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(
       "/:ptyID",
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         },
       }),
       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(
       "/:ptyID/connect",
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
         })()
         let handler: Handler | undefined
         if (
-          !(await AppRuntime.runPromise(
+          !(await runRequest(
+            "PtyRoutes.connect",
+            c,
             Effect.gen(function* () {
               const pty = yield* Pty.Service
               return yield* pty.get(id)
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
               Effect.gen(function* () {
                 const pty = yield* Pty.Service
                 return yield* pty.connect(id, socket, cursor)
-              }),
+              }).pipe(Effect.withSpan("PtyRoutes.connect.open")),
             )
             ready = true
             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 { QuestionID } from "@/question/schema"
 import { Question } from "@/question"
-import { AppRuntime } from "@/effect/app-runtime"
 import z from "zod"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
 
 const Reply = z.object({
   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(
       "/:requestID/reply",
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
         }),
       ),
       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(
       "/:requestID/reject",
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
           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 { Todo } from "@/session/todo"
 import { Effect } from "effect"
-import { AppRuntime } from "@/effect/app-runtime"
 import { Agent } from "@/agent/agent"
 import { Snapshot } from "@/snapshot"
 import { Command } from "@/command"
@@ -26,7 +25,7 @@ import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
 import { Bus } from "@/bus"
 import { NamedError } from "@opencode-ai/shared/util/error"
-import { jsonRequest } from "./trace"
+import { jsonRequest, runRequest } from "./trace"
 
 const log = Log.create({ service: "server" })
 
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
         },
       }),
       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(
       "/:sessionID",
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID",
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
             .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.
     .post(
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/fork",
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
         }),
       ),
       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(
       "/:sessionID/abort",
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/share",
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/diff",
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/share",
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/summarize",
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/message",
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
         const query = c.req.valid("query")
         const sessionID = c.req.valid("param").sessionID
         if (query.limit === undefined || query.limit === 0) {
-          const messages = await AppRuntime.runPromise(
+          const messages = await runRequest(
+            "SessionRoutes.messages",
+            c,
             Effect.gen(function* () {
               const session = yield* Session.Service
               yield* session.get(sessionID)
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/message/:messageID/part/:partID",
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
           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(
       "/: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}'`,
           )
         }
-        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(
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
         return stream(c, async (stream) => {
           const sessionID = c.req.valid("param").sessionID
           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 })),
           )
           void stream.write(JSON.stringify(msg))
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         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)
       },
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
         }),
       ),
       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(
       "/:sessionID/shell",
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
         }),
       ),
       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(
       "/:sessionID/revert",
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         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(
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
           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(
       "/:sessionID/permissions/:permissionID",
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
         }),
       ),
       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
 
+// 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>) {
-  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>(

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

@@ -4,10 +4,10 @@ import z from "zod"
 import { Bus } from "@/bus"
 import { Session } from "@/session"
 import { TuiEvent } from "@/cli/cmd/tui/event"
-import { AppRuntime } from "@/effect/app-runtime"
 import { AsyncQueue } from "@/util/queue"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
+import { runRequest } from "./trace"
 
 const TuiRequest = z.object({
   path: z.string(),
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
       validator("json", TuiEvent.SessionSelect.properties),
       async (c) => {
         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 })
         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 { GlobalRoutes } from "./routes/global"
 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
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -45,14 +47,9 @@ function create(opts: { cors?: string[] }) {
   if (Flag.OPENCODE_WORKSPACE_ID) {
     return {
       app: app
+        .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
         .use(FenceMiddleware)
-        .route(
-          "/",
-          InstanceRoutes(
-            runtime.upgradeWebSocket,
-            Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined,
-          ),
-        ),
+        .route("/", InstanceRoutes(runtime.upgradeWebSocket)),
       runtime,
     }
   }
@@ -60,7 +57,13 @@ function create(opts: { cors?: string[] }) {
   return {
     app: app
       .route("/", ControlPlaneRoutes())
-      .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
+      .route(
+        "/",
+        new Hono()
+          .use(InstanceMiddleware())
+          .route("/experimental/workspace", WorkspaceRoutes())
+          .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
+      )
       .route("/", InstanceRoutes(runtime.upgradeWebSocket))
       .route("/", UIRoutes()),
     runtime,

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

@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
 import { Session } from "@/session"
 import { SessionID } from "@/session/schema"
 import { AppRuntime } from "@/effect/app-runtime"
+import { Effect } from "effect"
 import { Log } from "@/util"
 import { ServerProxy } from "./proxy"
 
@@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) {
   const id = getSessionID(url)
   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
 }
 

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

@@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
 export type Share = typeof ShareSchema.Type
 
 type State = {
-  queue: Map<string, { data: Map<string, Data> }>
+  queue: Map<SessionID, Map<string, Data>>
   scope: Scope.Closeable
+  shared: Map<SessionID, Share | null>
 }
 
 type Data =
@@ -118,17 +119,20 @@ export const layer = Layer.effect(
     function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
       return Effect.gen(function* () {
         if (disabled) return
+        const share = yield* getCached(sessionID)
+        if (!share) return
+
         const s = yield* InstanceState.get(state)
         const existing = s.queue.get(sessionID)
         if (existing) {
           for (const item of data) {
-            existing.data.set(key(item), item)
+            existing.set(key(item), item)
           }
           return
         }
 
         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(
           Effect.delay(1000),
           Effect.catchCause((cause) =>
@@ -143,13 +147,14 @@ export const layer = Layer.effect(
 
     const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
       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(() =>
           Scope.close(cache.scope, Exit.void).pipe(
             Effect.andThen(
               Effect.sync(() => {
                 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
     })
 
+    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) {
       if (disabled) return
       const s = yield* InstanceState.get(state)
@@ -235,13 +252,13 @@ export const layer = Layer.effect(
 
       s.queue.delete(sessionID)
 
-      const share = yield* get(sessionID)
+      const share = yield* getCached(sessionID)
       if (!share) return
 
       const req = yield* request()
       const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
         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)),
       )
 
@@ -307,6 +324,7 @@ export const layer = Layer.effect(
           .run(),
       )
       const s = yield* InstanceState.get(state)
+      s.shared.set(sessionID, result)
       yield* full(sessionID).pipe(
         Effect.catchCause((cause) =>
           Effect.sync(() => {
@@ -321,8 +339,13 @@ export const layer = Layer.effect(
     const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
       if (disabled) return
       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()
       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())
+      s.shared.delete(sessionID)
+      s.queue.delete(sessionID)
     })
 
     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"
 
 /**
@@ -8,19 +8,97 @@ import z from "zod"
  */
 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>> {
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
 }
 
 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
   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 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 {
@@ -86,21 +164,40 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
 }
 
 function object(ast: SchemaAST.Objects): z.ZodTypeAny {
+  // Pure record: { [k: string]: V }
   if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
     const sig = ast.indexSignatures[0]
     if (sig.parameter._tag !== "String") return fail(ast)
     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 {
-  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 {

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

@@ -1,6 +1,6 @@
 import { Schema } from "effect"
 import { SessionEvent } from "./session-event"
-import { produce } from "immer"
+import { castDraft, produce } from "immer"
 
 export const ID = SessionEvent.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),
 }) {}
 
-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 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,
 }) {}
 
-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 class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
@@ -126,7 +130,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compacti
   ...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>
 
@@ -141,19 +145,29 @@ export function step(old: History, event: SessionEvent.Event): History {
   return produce(old, (draft) => {
     const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
     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) {
-          // @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
         draft.entries.push({
           id: event.id,
@@ -163,27 +177,28 @@ export function step(old: History, event: SessionEvent.Event): History {
           },
           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({
           type: "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
-        break
-      }
-      case "text.ended": {
-        break
-      }
-      case "tool.input.started": {
-        if (!pendingAssistant) break
+      },
+      "text.ended": () => {},
+      "tool.input.started": (event) => {
+        if (!pendingAssistant) return
         pendingAssistant.content.push({
           type: "tool",
           callID: event.callID,
@@ -196,21 +211,17 @@ export function step(old: History, event: SessionEvent.Event): History {
             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)
         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) {
           match.time.ran = event.timestamp
           match.state = {
@@ -218,11 +229,10 @@ export function step(old: History, event: SessionEvent.Event): History {
             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") {
           match.state = {
             status: "completed",
@@ -230,15 +240,13 @@ export function step(old: History, event: SessionEvent.Event): History {
             output: event.output ?? "",
             title: event.title,
             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") {
           match.state = {
             status: "error",
@@ -247,36 +255,29 @@ export function step(old: History, event: SessionEvent.Event): History {
             metadata: event.metadata ?? {},
           }
         }
-        break
-      }
-      case "reasoning.started": {
-        if (!pendingAssistant) break
+      },
+      "reasoning.started": () => {
+        if (!pendingAssistant) return
         pendingAssistant.content.push({
           type: "reasoning",
           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
-        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
-        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",
     },
-  )
+  ).pipe(Schema.toTaggedUnion("type"))
   export type Event = Schema.Schema.Type<typeof Event>
   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.property(word, (body) => {
           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.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
           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 { Schema } from "effect"
+import { Schema, SchemaGetter } from "effect"
 import z from "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", () => {
@@ -186,4 +210,272 @@ describe("util.effect-zod", () => {
     const schema = json(zod(Parent)) as any
     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",
   "name": "@opencode-ai/plugin",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "license": "MIT",
   "scripts": {

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

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/sdk",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "type": "module",
   "license": "MIT",
   "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 ToolListItem = {
@@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
   /**
    * Active Console provider metadata
    */
-  200: {
-    consoleManagedProviders: Array<string>
-    activeOrgName?: string
-    switchableOrgCount: number
-  }
+  200: ConsoleState
 }
 
 export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]

+ 26 - 36
packages/sdk/openapi.json

@@ -1607,24 +1607,7 @@
             "content": {
               "application/json": {
                 "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.",
                 "anyOf": [
                   {
-                    "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
                     "type": "integer",
                     "exclusiveMinimum": 0,
                     "maximum": 9007199254740991
                   },
                   {
-                    "description": "Disable timeout for this provider entirely.",
                     "type": "boolean",
                     "const": false
                   }
@@ -11264,8 +11245,7 @@
                           "enum": ["reasoning_content", "reasoning_details"]
                         }
                       },
-                      "required": ["field"],
-                      "additionalProperties": false
+                      "required": ["field"]
                     }
                   ]
                 },
@@ -11394,8 +11374,7 @@
               }
             }
           }
-        },
-        "additionalProperties": false
+        }
       },
       "McpLocalConfig": {
         "type": "object",
@@ -11428,13 +11407,10 @@
           },
           "timeout": {
             "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": {
         "type": "object",
@@ -11455,8 +11431,7 @@
             "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
             "type": "string"
           }
-        },
-        "additionalProperties": false
+        }
       },
       "McpRemoteConfig": {
         "type": "object",
@@ -11498,13 +11473,10 @@
           },
           "timeout": {
             "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": {
         "description": "@deprecated Always uses stretch layout.",
@@ -12366,6 +12338,24 @@
         },
         "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": {
         "type": "array",
         "items": {

+ 1 - 1
packages/shared/package.json

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

+ 1 - 1
packages/slack/package.json

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

+ 1 - 1
packages/ui/package.json

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

+ 1 - 1
packages/web/package.json

@@ -2,7 +2,7 @@
   "name": "@opencode-ai/web",
   "type": "module",
   "license": "MIT",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "scripts": {
     "dev": "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",
   "displayName": "opencode",
   "description": "opencode for VS Code",
-  "version": "1.4.9",
+  "version": "1.4.11",
   "publisher": "sst-dev",
   "repository": {
     "type": "git",