Browse Source

Merge branch 'dev' into openai-compaction

Aiden Cline 3 ngày trước cách đây
mục cha
commit
55cd69805c
85 tập tin đã thay đổi với 1790 bổ sung1477 xóa
  1. 21 23
      bun.lock
  2. 4 4
      nix/hashes.json
  3. 1 1
      package.json
  4. 1 1
      packages/app/package.json
  5. 1 1
      packages/console/app/package.json
  6. 1 1
      packages/console/core/package.json
  7. 1 1
      packages/console/function/package.json
  8. 1 1
      packages/console/mail/package.json
  9. 1 1
      packages/desktop-electron/package.json
  10. 1 1
      packages/desktop/package.json
  11. 1 1
      packages/enterprise/package.json
  12. 6 6
      packages/extensions/zed/extension.toml
  13. 1 1
      packages/function/package.json
  14. 2 2
      packages/opencode/package.json
  15. 58 19
      packages/opencode/specs/effect/http-api.md
  16. 2 7
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  17. 7 1
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  18. 81 0
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
  19. 44 9
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  20. 1 1
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  21. 1 1
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  22. 1 1
      packages/opencode/src/cli/cmd/tui/layer.ts
  23. 20 0
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  24. 6 3
      packages/opencode/src/cli/cmd/tui/thread.ts
  25. 3 0
      packages/opencode/src/cli/cmd/tui/worker.ts
  26. 1 1
      packages/opencode/src/config/agent.ts
  27. 12 10
      packages/opencode/src/config/command.ts
  28. 11 11
      packages/opencode/src/config/config.ts
  29. 11 10
      packages/opencode/src/config/console-state.ts
  30. 13 9
      packages/opencode/src/config/formatter.ts
  31. 35 27
      packages/opencode/src/config/lsp.ts
  32. 56 62
      packages/opencode/src/config/mcp.ts
  33. 12 1
      packages/opencode/src/config/model-id.ts
  34. 33 30
      packages/opencode/src/config/permission.ts
  35. 9 5
      packages/opencode/src/config/plugin.ts
  36. 12 9
      packages/opencode/src/config/skills.ts
  37. 1 1
      packages/opencode/src/control-plane/types.ts
  38. 2 0
      packages/opencode/src/control-plane/workspace.ts
  39. 1 1
      packages/opencode/src/effect/app-runtime.ts
  40. 30 20
      packages/opencode/src/effect/observability.ts
  41. 2 3
      packages/opencode/src/file/ripgrep.ts
  42. 2 3
      packages/opencode/src/file/ripgrep.worker.ts
  43. 5 0
      packages/opencode/src/index.ts
  44. 0 258
      packages/opencode/src/npm/effect.ts
  45. 237 164
      packages/opencode/src/npm/index.ts
  46. 42 0
      packages/opencode/src/plugin/loader.ts
  47. 1 1
      packages/opencode/src/plugin/shared.ts
  48. 7 7
      packages/opencode/src/provider/provider.ts
  49. 0 2
      packages/opencode/src/server/routes/control/index.ts
  50. 7 6
      packages/opencode/src/server/routes/instance/config.ts
  51. 80 92
      packages/opencode/src/server/routes/instance/experimental.ts
  52. 34 54
      packages/opencode/src/server/routes/instance/file.ts
  53. 40 48
      packages/opencode/src/server/routes/instance/index.ts
  54. 48 34
      packages/opencode/src/server/routes/instance/mcp.ts
  55. 35 0
      packages/opencode/src/server/routes/instance/middleware.ts
  56. 18 19
      packages/opencode/src/server/routes/instance/permission.ts
  57. 11 7
      packages/opencode/src/server/routes/instance/project.ts
  58. 53 64
      packages/opencode/src/server/routes/instance/provider.ts
  59. 29 40
      packages/opencode/src/server/routes/instance/pty.ts
  60. 24 23
      packages/opencode/src/server/routes/instance/question.ts
  61. 199 212
      packages/opencode/src/server/routes/instance/session.ts
  62. 6 2
      packages/opencode/src/server/routes/instance/tui.ts
  63. 9 36
      packages/opencode/src/server/server.ts
  64. 4 1
      packages/opencode/src/server/workspace.ts
  65. 1 1
      packages/opencode/src/session/processor.ts
  66. 1 1
      packages/opencode/src/session/session.ts
  67. 33 8
      packages/opencode/src/share/share-next.ts
  68. 32 4
      packages/opencode/src/util/effect-zod.ts
  69. 24 0
      packages/opencode/src/util/opencode-process.ts
  70. 6 6
      packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
  71. 1 1
      packages/opencode/test/config/config.test.ts
  72. 87 0
      packages/opencode/test/config/lsp.test.ts
  73. 10 10
      packages/opencode/test/plugin/loader-shared.test.ts
  74. 33 1
      packages/opencode/test/provider/provider.test.ts
  75. 79 2
      packages/opencode/test/util/effect-zod.test.ts
  76. 1 1
      packages/plugin/package.json
  77. 1 1
      packages/sdk/js/package.json
  78. 7 5
      packages/sdk/js/src/v2/gen/types.gen.ts
  79. 24 30
      packages/sdk/openapi.json
  80. 1 1
      packages/shared/package.json
  81. 46 41
      packages/shared/src/util/effect-flock.ts
  82. 1 1
      packages/slack/package.json
  83. 1 1
      packages/ui/package.json
  84. 1 1
      packages/web/package.json
  85. 1 1
      sdks/vscode/package.json

+ 21 - 23
bun.lock

@@ -29,7 +29,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.4.9",
+      "version": "1.4.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "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.10",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -328,7 +328,7 @@
         "@ai-sdk/cerebras": "2.0.41",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
-        "@ai-sdk/gateway": "3.0.102",
+        "@ai-sdk/gateway": "3.0.104",
         "@ai-sdk/google": "3.0.63",
         "@ai-sdk/google-vertex": "4.0.112",
         "@ai-sdk/groq": "3.0.31",
@@ -458,7 +458,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.4.9",
+      "version": "1.4.10",
       "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.10",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -508,7 +508,7 @@
     },
     "packages/shared": {
       "name": "@opencode-ai/shared",
-      "version": "1.4.9",
+      "version": "1.4.10",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -532,7 +532,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.4.9",
+      "version": "1.4.10",
       "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.10",
       "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.10",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -693,7 +693,7 @@
     "@types/node": "22.13.9",
     "@types/semver": "7.7.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
-    "ai": "6.0.158",
+    "ai": "6.0.168",
     "cross-spawn": "7.0.6",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
@@ -761,7 +761,7 @@
 
     "@ai-sdk/fireworks": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
 
-    "@ai-sdk/gateway": ["@ai-sdk/[email protected]2", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
+    "@ai-sdk/gateway": ["@ai-sdk/[email protected]4", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
 
     "@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
 
@@ -2457,7 +2457,7 @@
 
     "@valibot/to-json-schema": ["@valibot/[email protected]", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
 
-    "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
+    "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
 
     "@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
 
@@ -2517,7 +2517,7 @@
 
     "agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
 
-    "ai": ["[email protected]58", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
+    "ai": ["[email protected]68", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
 
     "ai-gateway-provider": ["[email protected]", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
 
@@ -5703,8 +5703,6 @@
 
     "accepts/negotiator": ["[email protected]", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
 
-    "ai/@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
-
     "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
 
     "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=",
-    "aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=",
-    "aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=",
-    "x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc="
+    "x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
+    "aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
+    "aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
+    "x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
   }
 }

+ 1 - 1
package.json

@@ -53,7 +53,7 @@
       "drizzle-kit": "1.0.0-beta.19-d95b7a4",
       "drizzle-orm": "1.0.0-beta.19-d95b7a4",
       "effect": "4.0.0-beta.48",
-      "ai": "6.0.158",
+      "ai": "6.0.168",
       "cross-spawn": "7.0.6",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.4.9",
+  "version": "1.4.10",
   "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.10",
   "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.10",
   "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.10",
   "$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.10",
   "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.10",
   "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.10",
   "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.10",
   "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.10"
 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.10/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.10/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.10/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.10/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.10/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.10",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 2 - 2
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.4.9",
+  "version": "1.4.10",
   "name": "opencode",
   "type": "module",
   "license": "MIT",
@@ -85,7 +85,7 @@
     "@ai-sdk/cerebras": "2.0.41",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/deepinfra": "2.0.41",
-    "@ai-sdk/gateway": "3.0.102",
+    "@ai-sdk/gateway": "3.0.104",
     "@ai-sdk/google": "3.0.63",
     "@ai-sdk/google-vertex": "4.0.112",
     "@ai-sdk/groq": "3.0.31",

+ 58 - 19
packages/opencode/specs/effect/http-api.md

@@ -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:
 
@@ -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

+ 2 - 7
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -139,15 +139,10 @@ export function DialogSessionList() {
                 {desc}{" "}
                 <span
                   style={{
-                    fg:
-                      workspaceStatus === "error"
-                        ? theme.error
-                        : workspaceStatus === "disconnected"
-                          ? theme.textMuted
-                          : theme.success,
+                    fg: workspaceStatus === "connected" ? theme.success : theme.error,
                   }}
                 >
-                  
+                  ●
                 </span>
               </>
             )

+ 7 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
     total: result.data.total,
   })
 
-  await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
+  input.project.workspace.set(input.workspaceID)
+
+  try {
+    await input.sync.bootstrap({ fatal: false })
+  } catch (e) {}
+
+  await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
     log.error("session restore refresh failed", {
       workspaceID: input.workspaceID,
       sessionID: input.sessionID,

+ 81 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx

@@ -0,0 +1,81 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+
+export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  const [store, setStore] = createStore({
+    active: "restore" as "cancel" | "restore",
+  })
+
+  const options = ["cancel", "restore"] as const
+
+  async function confirm() {
+    if (store.active === "cancel") {
+      dialog.clear()
+      return
+    }
+    const result = await props.onRestore?.()
+    if (result === false) return
+  }
+
+  useKeyboard((evt) => {
+    if (evt.name === "return") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      void confirm()
+      return
+    }
+    if (evt.name === "left") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      setStore("active", "cancel")
+      return
+    }
+    if (evt.name === "right") {
+      evt.preventDefault()
+      evt.stopPropagation()
+      setStore("active", "restore")
+    }
+  })
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Workspace Unavailable
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <text fg={theme.textMuted} wrapMode="word">
+        This session is attached to a workspace that is no longer available.
+      </text>
+      <text fg={theme.textMuted} wrapMode="word">
+        Would you like to restore this session into a new workspace?
+      </text>
+      <box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
+        <For each={options}>
+          {(item) => (
+            <box
+              paddingLeft={2}
+              paddingRight={2}
+              backgroundColor={item === store.active ? theme.primary : undefined}
+              onMouseUp={() => {
+                setStore("active", item)
+                void confirm()
+              }}
+            >
+              <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
+            </box>
+          )}
+        </For>
+      </box>
+    </box>
+  )
+}

+ 44 - 9
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -9,6 +9,7 @@ import { tint, useTheme } from "@tui/context/theme"
 import { EmptyBorder, SplitBorder } from "@tui/component/border"
 import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
+import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { useEvent } from "@tui/context/event"
 import { MessageID, PartID } from "@/session/schema"
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
+import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
+import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
 
 export type PromptProps = {
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
   const args = useArgs()
   const sdk = useSDK()
   const route = useRoute()
+  const project = useProject()
   const sync = useSync()
   const dialog = useDialog()
   const toast = useToast()
@@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
         keybind: "input_submit",
         category: "Prompt",
         hidden: true,
-        onSelect: (dialog) => {
+        onSelect: async (dialog) => {
           if (!input.focused) return
-          void submit()
+          const handled = await submit()
+          if (!handled) return
+
           dialog.clear()
         },
       },
@@ -628,20 +634,48 @@ export function Prompt(props: PromptProps) {
       setStore("prompt", "input", input.plainText)
       syncExtmarksWithPromptParts()
     }
-    if (props.disabled) return
-    if (autocomplete?.visible) return
-    if (!store.prompt.input) return
+    if (props.disabled) return false
+    if (autocomplete?.visible) return false
+    if (!store.prompt.input) return false
     const agent = local.agent.current()
-    if (!agent) return
+    if (!agent) return false
     const trimmed = store.prompt.input.trim()
     if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
       void exit()
-      return
+      return true
     }
     const selectedModel = local.model.current()
     if (!selectedModel) {
       void promptModelWarning()
-      return
+      return false
+    }
+
+    const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
+    const workspaceID = workspaceSession?.workspaceID
+    const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
+    if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
+      dialog.replace(() => (
+        <DialogWorkspaceUnavailable
+          onRestore={() => {
+            dialog.replace(() => (
+              <DialogWorkspaceCreate
+                onSelect={(nextWorkspaceID) =>
+                  restoreWorkspaceSession({
+                    dialog,
+                    sdk,
+                    sync,
+                    project,
+                    toast,
+                    workspaceID: nextWorkspaceID,
+                    sessionID: props.sessionID!,
+                  })
+                }
+              />
+            ))
+          }}
+        />
+      ))
+      return false
     }
 
     let sessionID = props.sessionID
@@ -656,7 +690,7 @@ export function Prompt(props: PromptProps) {
           variant: "error",
         })
 
-        return
+        return true
       }
 
       sessionID = res.data.id
@@ -770,6 +804,7 @@ export function Prompt(props: PromptProps) {
         })
       }, 50)
     input.clear()
+    return true
   }
   const exit = useExit()
 

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

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

@@ -18,7 +18,7 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
 import { makeRuntime } from "@/effect/runtime"
 import { Filesystem, Log } from "@/util"
 import { ConfigVariable } from "@/config/variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 const log = Log.create({ service: "tui.config" })
 

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

@@ -1,6 +1,6 @@
 import { Layer } from "effect"
 import { TuiConfig } from "./config/tui"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 import { Observability } from "@/effect/observability"
 
 export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

+ 20 - 0
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -1,3 +1,4 @@
+import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
 import { createMemo, Show } from "solid-js"
 import { useTheme } from "../../context/theme"
@@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
 import { getScrollAcceleration } from "../../util/scroll"
 
 export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
+  const project = useProject()
   const sync = useSync()
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
   const session = createMemo(() => sync.session.get(props.sessionID))
+  const workspaceStatus = () => {
+    const workspaceID = session()?.workspaceID
+    if (!workspaceID) return "error"
+    return project.workspace.status(workspaceID) ?? "error"
+  }
+  const workspaceLabel = () => {
+    const workspaceID = session()?.workspaceID
+    if (!workspaceID) return "unknown"
+    const info = project.workspace.get(workspaceID)
+    if (!info) return "unknown"
+    return `${info.type}: ${info.name}`
+  }
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
 
   return (
@@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 <text fg={theme.text}>
                   <b>{session()!.title}</b>
                 </text>
+                <Show when={session()!.workspaceID}>
+                  <text fg={theme.textMuted}>
+                    <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
+                    {workspaceLabel()}
+                  </text>
+                </Show>
                 <Show when={session()!.share?.url}>
                   <text fg={theme.textMuted}>{session()!.share!.url}</text>
                 </Show>

+ 6 - 3
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { writeHeapSnapshot } from "v8"
 import { TuiConfig } from "./config/tui"
+import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
         return
       }
       const cwd = Filesystem.resolve(process.cwd())
+      const env = sanitizedProcessEnv({
+        [OPENCODE_PROCESS_ROLE]: "worker",
+        [OPENCODE_RUN_ID]: ensureRunID(),
+      })
 
       const worker = new Worker(file, {
-        env: Object.fromEntries(
-          Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
-        ),
+        env,
       })
       worker.onerror = (e) => {
         Log.Default.error("thread error", {

+ 3 - 0
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
 import { writeHeapSnapshot } from "node:v8"
 import { Heap } from "@/cli/heap"
 import { AppRuntime } from "@/effect/app-runtime"
+import { ensureProcessMetadata } from "@/util/opencode-process"
+
+ensureProcessMetadata("worker")
 
 await Log.init({
   print: process.argv.includes("--print-logs"),

+ 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

+ 11 - 11
packages/opencode/src/config/config.ts

@@ -38,7 +38,7 @@ import { ConfigPaths } from "./paths"
 import { ConfigFormatter } from "./formatter"
 import { ConfigLSP } from "./lsp"
 import { ConfigVariable } from "./variable"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 const log = Log.create({ service: "config" })
 
@@ -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()
@@ -178,7 +178,7 @@ export const Info = 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(),

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

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

+ 1 - 1
packages/opencode/src/control-plane/types.ts

@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
   name: string
   description: string
   configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
-  create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
+  create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
   remove(info: WorkspaceInfo): Promise<void>
   target(info: WorkspaceInfo): Target | Promise<Target>
 }

+ 2 - 0
packages/opencode/src/control-plane/workspace.ts

@@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => {
     OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
     OPENCODE_WORKSPACE_ID: config.id,
     OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
+    OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
+    OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
   }
   await adaptor.create(config, env)
 

+ 1 - 1
packages/opencode/src/effect/app-runtime.ts

@@ -46,7 +46,7 @@ import { Pty } from "@/pty"
 import { Installation } from "@/installation"
 import { ShareNext } from "@/share"
 import { SessionShare } from "@/share"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 import { memoMap } from "./memo-map"
 
 export const AppLayer = Layer.mergeAll(

+ 30 - 20
packages/opencode/src/effect/observability.ts

@@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
 import * as EffectLogger from "./logger"
 import { Flag } from "@/flag/flag"
 import { InstallationChannel, InstallationVersion } from "@/installation/version"
+import { ensureProcessMetadata } from "@/util/opencode-process"
 
 const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
 export const enabled = !!base
+const processID = crypto.randomUUID()
 
 const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
   ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
     )
   : undefined
 
-const resource = {
-  serviceName: "opencode",
-  serviceVersion: InstallationVersion,
-  attributes: {
-    "deployment.environment.name": InstallationChannel,
-    "opencode.client": Flag.OPENCODE_CLIENT,
-  },
+function resource() {
+  const processMetadata = ensureProcessMetadata("main")
+  return {
+    serviceName: "opencode",
+    serviceVersion: InstallationVersion,
+    attributes: {
+      "deployment.environment.name": InstallationChannel,
+      "opencode.client": Flag.OPENCODE_CLIENT,
+      "opencode.process_role": processMetadata.processRole,
+      "opencode.run_id": processMetadata.runID,
+      "service.instance.id": processID,
+    },
+  }
 }
 
-const logs = Logger.layer(
-  [
-    EffectLogger.logger,
-    OtlpLogger.make({
-      url: `${base}/v1/logs`,
-      resource,
-      headers,
-    }),
-  ],
-  { mergeWithExisting: false },
-).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
+function logs() {
+  return Logger.layer(
+    [
+      EffectLogger.logger,
+      OtlpLogger.make({
+        url: `${base}/v1/logs`,
+        resource: resource(),
+        headers,
+      }),
+    ],
+    { mergeWithExisting: false },
+  ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
+}
 
 const traces = async () => {
   const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@@ -58,7 +68,7 @@ const traces = async () => {
   context.setGlobalContextManager(mgr)
 
   return NodeSdk.layer(() => ({
-    resource,
+    resource: resource(),
     spanProcessor: new SdkBase.BatchSpanProcessor(
       new OTLP.OTLPTraceExporter({
         url: `${base}/v1/traces`,
@@ -73,7 +83,7 @@ export const layer = !base
   : Layer.unwrap(
       Effect.gen(function* () {
         const trace = yield* Effect.promise(traces)
-        return Layer.mergeAll(trace, logs)
+        return Layer.mergeAll(trace, logs())
       }),
     )
 

+ 2 - 3
packages/opencode/src/file/ripgrep.ts

@@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep"
 
 import { Filesystem } from "@/util"
 import { Log } from "@/util"
+import { sanitizedProcessEnv } from "@/util/opencode-process"
 
 const log = Log.create({ service: "ripgrep" })
 
@@ -157,9 +158,7 @@ type WorkerError = {
 }
 
 function env() {
-  const env = Object.fromEntries(
-    Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
-  )
+  const env = sanitizedProcessEnv()
   delete env.RIPGREP_CONFIG_PATH
   return env
 }

+ 2 - 3
packages/opencode/src/file/ripgrep.worker.ts

@@ -1,9 +1,8 @@
 import { ripgrep } from "ripgrep"
+import { sanitizedProcessEnv } from "@/util/opencode-process"
 
 function env() {
-  const env = Object.fromEntries(
-    Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
-  )
+  const env = sanitizedProcessEnv()
   delete env.RIPGREP_CONFIG_PATH
   return env
 }

+ 5 - 0
packages/opencode/src/index.ts

@@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
 import { PluginCommand } from "./cli/cmd/plug"
 import { Heap } from "./cli/heap"
 import { drizzle } from "drizzle-orm/bun-sqlite"
+import { ensureProcessMetadata } from "./util/opencode-process"
+
+const processMetadata = ensureProcessMetadata("main")
 
 process.on("unhandledRejection", (e) => {
   Log.Default.error("rejection", {
@@ -108,6 +111,8 @@ const cli = yargs(args)
     Log.Default.info("opencode", {
       version: InstallationVersion,
       args: process.argv.slice(2),
+      process_role: processMetadata.processRole,
+      run_id: processMetadata.runID,
     })
 
     const marker = path.join(Global.Path.data, "opencode.db")

+ 0 - 258
packages/opencode/src/npm/effect.ts

@@ -1,258 +0,0 @@
-export * as Npm from "./effect"
-
-import path from "path"
-import semver from "semver"
-import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
-import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Global } from "@opencode-ai/shared/global"
-import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
-
-import { makeRuntime } from "../effect/runtime"
-
-export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
-  add: Schema.Array(Schema.String).pipe(Schema.optional),
-  dir: Schema.String,
-  cause: Schema.optional(Schema.Defect),
-}) {}
-
-export interface EntryPoint {
-  readonly directory: string
-  readonly entrypoint: Option.Option<string>
-}
-
-export interface Interface {
-  readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
-  readonly install: (
-    dir: string,
-    input?: { add: 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>>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
-
-const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
-
-export function sanitize(pkg: string) {
-  if (!illegal) return pkg
-  return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
-}
-
-const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
-  let entrypoint: Option.Option<string>
-  try {
-    const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
-    entrypoint = Option.some(resolved)
-  } catch {
-    entrypoint = Option.none()
-  }
-  return {
-    directory: dir,
-    entrypoint,
-  }
-}
-
-interface ArboristNode {
-  name: string
-  path: string
-}
-
-interface ArboristTree {
-  edgesOut: Map<string, { to?: ArboristNode }>
-}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const afs = yield* AppFileSystem.Service
-    const global = yield* Global.Service
-    const fs = yield* FileSystem.FileSystem
-    const flock = yield* EffectFlock.Service
-    const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
-    const reify = (input: { dir: string; add?: string[] }) =>
-      Effect.gen(function* () {
-        yield* flock.acquire(`npm-install:${input.dir}`)
-        const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
-        const arborist = new Arborist({
-          path: input.dir,
-          binLinks: true,
-          progress: false,
-          savePrefix: "",
-          ignoreScripts: true,
-        })
-        return yield* Effect.tryPromise({
-          try: () =>
-            arborist.reify({
-              add: input?.add || [],
-              save: true,
-              saveType: "prod",
-            }),
-          catch: (cause) =>
-            new InstallFailedError({
-              cause,
-              add: input?.add,
-              dir: input.dir,
-            }),
-        }) as Effect.Effect<ArboristTree, InstallFailedError>
-      }).pipe(
-        Effect.withSpan("Npm.reify", {
-          attributes: input,
-        }),
-      )
-
-    const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
-      const response = yield* Effect.tryPromise({
-        try: () => fetch(`https://registry.npmjs.org/${pkg}`),
-        catch: () => undefined,
-      }).pipe(Effect.orElseSucceed(() => undefined))
-
-      if (!response || !response.ok) {
-        return false
-      }
-
-      const data = yield* Effect.tryPromise({
-        try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
-        catch: () => undefined,
-      }).pipe(Effect.orElseSucceed(() => undefined))
-
-      const latestVersion = data?.["dist-tags"]?.latest
-      if (!latestVersion) {
-        return false
-      }
-
-      const range = /[\s^~*xX<>|=]/.test(cachedVersion)
-      if (range) return !semver.satisfies(latestVersion, cachedVersion)
-
-      return semver.lt(cachedVersion, latestVersion)
-    })
-
-    const add = Effect.fn("Npm.add")(function* (pkg: string) {
-      const dir = directory(pkg)
-
-      const tree = yield* reify({ dir, add: [pkg] })
-      const first = tree.edgesOut.values().next().value?.to
-      if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
-      return resolveEntryPoint(first.name, first.path)
-    }, Effect.scoped)
-
-    const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
-      const canWrite = yield* afs.access(dir, { writable: true }).pipe(
-        Effect.as(true),
-        Effect.orElseSucceed(() => false),
-      )
-      if (!canWrite) return
-
-      yield* Effect.gen(function* () {
-        const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
-        if (!nodeModulesExists) {
-          yield* reify({ add: input?.add, dir })
-          return
-        }
-      }).pipe(Effect.withSpan("Npm.checkNodeModules"))
-
-      yield* Effect.gen(function* () {
-        const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
-        const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
-
-        const pkgAny = pkg as any
-        const lockAny = lock as any
-        const declared = new Set([
-          ...Object.keys(pkgAny?.dependencies || {}),
-          ...Object.keys(pkgAny?.devDependencies || {}),
-          ...Object.keys(pkgAny?.peerDependencies || {}),
-          ...Object.keys(pkgAny?.optionalDependencies || {}),
-          ...(input?.add || []),
-        ])
-
-        const root = lockAny?.packages?.[""] || {}
-        const locked = new Set([
-          ...Object.keys(root?.dependencies || {}),
-          ...Object.keys(root?.devDependencies || {}),
-          ...Object.keys(root?.peerDependencies || {}),
-          ...Object.keys(root?.optionalDependencies || {}),
-        ])
-
-        for (const name of declared) {
-          if (!locked.has(name)) {
-            yield* reify({ dir, add: input?.add })
-            return
-          }
-        }
-      }).pipe(Effect.withSpan("Npm.checkDirty"))
-
-      return
-    }, Effect.scoped)
-
-    const which = Effect.fn("Npm.which")(function* (pkg: string) {
-      const dir = directory(pkg)
-      const binDir = path.join(dir, "node_modules", ".bin")
-
-      const pick = Effect.fnUntraced(function* () {
-        const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
-
-        if (files.length === 0) return Option.none<string>()
-        if (files.length === 1) return Option.some(files[0])
-
-        const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
-
-        if (Option.isSome(pkgJson)) {
-          const parsed = pkgJson.value as { bin?: string | Record<string, string> }
-          if (parsed?.bin) {
-            const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
-            const bin = parsed.bin
-            if (typeof bin === "string") return Option.some(unscoped)
-            const keys = Object.keys(bin)
-            if (keys.length === 1) return Option.some(keys[0])
-            return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
-          }
-        }
-
-        return Option.some(files[0])
-      })
-
-      return yield* Effect.gen(function* () {
-        const bin = yield* pick()
-        if (Option.isSome(bin)) {
-          return Option.some(path.join(binDir, bin.value))
-        }
-
-        yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
-
-        yield* add(pkg)
-
-        const resolved = yield* pick()
-        if (Option.isNone(resolved)) return Option.none<string>()
-        return Option.some(path.join(binDir, resolved.value))
-      }).pipe(
-        Effect.scoped,
-        Effect.orElseSucceed(() => Option.none<string>()),
-      )
-    })
-
-    return Service.of({
-      add,
-      install,
-      outdated,
-      which,
-    })
-  }),
-)
-
-export const defaultLayer = layer.pipe(
-  Layer.provide(EffectFlock.layer),
-  Layer.provide(AppFileSystem.layer),
-  Layer.provide(Global.layer),
-  Layer.provide(NodeFileSystem.layer),
-)
-
-const { runPromise } = makeRuntime(Service, defaultLayer)
-
-export async function install(...args: Parameters<Interface["install"]>) {
-  return runPromise((svc) => svc.install(...args))
-}
-
-export async function add(...args: Parameters<Interface["add"]>) {
-  return runPromise((svc) => svc.add(...args))
-}

+ 237 - 164
packages/opencode/src/npm/index.ts

@@ -1,198 +1,271 @@
-import semver from "semver"
-import z from "zod"
-import { NamedError } from "@opencode-ai/shared/util/error"
-import { Global } from "../global"
-import { Log } from "../util"
+export * as Npm from "."
+
 import path from "path"
-import { readdir, rm } from "fs/promises"
-import { Filesystem } from "@/util"
-import { Flock } from "@opencode-ai/shared/util/flock"
+import semver from "semver"
+import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
+import { NodeFileSystem } from "@effect/platform-node"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Global } from "@opencode-ai/shared/global"
+import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
 
-const log = Log.create({ service: "npm" })
-const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
+import { makeRuntime } from "../effect/runtime"
 
-export const InstallFailedError = NamedError.create(
-  "NpmInstallFailedError",
-  z.object({
-    pkg: z.string(),
-  }),
-)
+export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
+  add: Schema.Array(Schema.String).pipe(Schema.optional),
+  dir: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export interface EntryPoint {
+  readonly directory: string
+  readonly entrypoint: Option.Option<string>
+}
+
+export interface Interface {
+  readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
+  readonly install: (
+    dir: string,
+    input?: { add: 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>>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
+
+const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
 
 export function sanitize(pkg: string) {
   if (!illegal) return pkg
   return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
 }
 
-function directory(pkg: string) {
-  return path.join(Global.Path.cache, "packages", sanitize(pkg))
-}
-
-function resolveEntryPoint(name: string, dir: string) {
-  let entrypoint: string | undefined
+const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
+  let entrypoint: Option.Option<string>
   try {
-    entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
-  } catch {}
-  const result = {
+    const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
+    entrypoint = Option.some(resolved)
+  } catch {
+    entrypoint = Option.none()
+  }
+  return {
     directory: dir,
     entrypoint,
   }
-  return result
 }
 
-export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
-  const response = await fetch(`https://registry.npmjs.org/${pkg}`)
-  if (!response.ok) {
-    log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
-    return false
-  }
+interface ArboristNode {
+  name: string
+  path: string
+}
 
-  const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
-  const latestVersion = data?.["dist-tags"]?.latest
-  if (!latestVersion) {
-    log.warn("No latest version found, using cached", { pkg, cachedVersion })
-    return false
-  }
+interface ArboristTree {
+  edgesOut: Map<string, { to?: ArboristNode }>
+}
 
-  const range = /[\s^~*xX<>|=]/.test(cachedVersion)
-  if (range) return !semver.satisfies(latestVersion, cachedVersion)
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const afs = yield* AppFileSystem.Service
+    const global = yield* Global.Service
+    const fs = yield* FileSystem.FileSystem
+    const flock = yield* EffectFlock.Service
+    const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
+    const reify = (input: { dir: string; add?: string[] }) =>
+      Effect.gen(function* () {
+        yield* flock.acquire(`npm-install:${input.dir}`)
+        const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
+        const arborist = new Arborist({
+          path: input.dir,
+          binLinks: true,
+          progress: false,
+          savePrefix: "",
+          ignoreScripts: true,
+        })
+        return yield* Effect.tryPromise({
+          try: () =>
+            arborist.reify({
+              add: input?.add || [],
+              save: true,
+              saveType: "prod",
+            }),
+          catch: (cause) =>
+            new InstallFailedError({
+              cause,
+              add: input?.add,
+              dir: input.dir,
+            }),
+        }) as Effect.Effect<ArboristTree, InstallFailedError>
+      }).pipe(
+        Effect.withSpan("Npm.reify", {
+          attributes: input,
+        }),
+      )
 
-  return semver.lt(cachedVersion, latestVersion)
-}
+    const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
+      const response = yield* Effect.tryPromise({
+        try: () => fetch(`https://registry.npmjs.org/${pkg}`),
+        catch: () => undefined,
+      }).pipe(Effect.orElseSucceed(() => undefined))
 
-export async function add(pkg: string) {
-  const { Arborist } = await import("@npmcli/arborist")
-  const dir = directory(pkg)
-  await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
-  log.info("installing package", {
-    pkg,
-  })
-
-  const arborist = new Arborist({
-    path: dir,
-    binLinks: true,
-    progress: false,
-    savePrefix: "",
-    ignoreScripts: true,
-  })
-  const tree = await arborist.loadVirtual().catch(() => {})
-  if (tree) {
-    const first = tree.edgesOut.values().next().value?.to
-    if (first) {
-      return resolveEntryPoint(first.name, first.path)
-    }
-  }
+      if (!response || !response.ok) {
+        return false
+      }
+
+      const data = yield* Effect.tryPromise({
+        try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
+        catch: () => undefined,
+      }).pipe(Effect.orElseSucceed(() => undefined))
+
+      const latestVersion = data?.["dist-tags"]?.latest
+      if (!latestVersion) {
+        return false
+      }
+
+      const range = /[\s^~*xX<>|=]/.test(cachedVersion)
+      if (range) return !semver.satisfies(latestVersion, cachedVersion)
 
-  const result = await arborist
-    .reify({
-      add: [pkg],
-      save: true,
-      saveType: "prod",
+      return semver.lt(cachedVersion, latestVersion)
     })
-    .catch((cause) => {
-      throw new InstallFailedError(
-        { pkg },
-        {
-          cause,
-        },
+
+    const add = Effect.fn("Npm.add")(function* (pkg: string) {
+      const dir = directory(pkg)
+
+      const tree = yield* reify({ dir, add: [pkg] })
+      const first = tree.edgesOut.values().next().value?.to
+      if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
+      return resolveEntryPoint(first.name, first.path)
+    }, Effect.scoped)
+
+    const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
+      const canWrite = yield* afs.access(dir, { writable: true }).pipe(
+        Effect.as(true),
+        Effect.orElseSucceed(() => false),
       )
-    })
+      if (!canWrite) return
 
-  const first = result.edgesOut.values().next().value?.to
-  if (!first) throw new InstallFailedError({ pkg })
-  return resolveEntryPoint(first.name, first.path)
-}
+      yield* Effect.gen(function* () {
+        const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
+        if (!nodeModulesExists) {
+          yield* reify({ add: input?.add, dir })
+          return
+        }
+      }).pipe(Effect.withSpan("Npm.checkNodeModules"))
 
-export async function install(dir: string) {
-  await using _ = await Flock.acquire(`npm-install:${dir}`)
-  log.info("checking dependencies", { dir })
-
-  const reify = async () => {
-    const { Arborist } = await import("@npmcli/arborist")
-    const arb = new Arborist({
-      path: dir,
-      binLinks: true,
-      progress: false,
-      savePrefix: "",
-      ignoreScripts: true,
-    })
-    await arb.reify().catch(() => {})
-  }
+      yield* Effect.gen(function* () {
+        const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
+        const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
 
-  if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
-    log.info("node_modules missing, reifying")
-    await reify()
-    return
-  }
+        const pkgAny = pkg as any
+        const lockAny = lock as any
+        const declared = new Set([
+          ...Object.keys(pkgAny?.dependencies || {}),
+          ...Object.keys(pkgAny?.devDependencies || {}),
+          ...Object.keys(pkgAny?.peerDependencies || {}),
+          ...Object.keys(pkgAny?.optionalDependencies || {}),
+          ...(input?.add || []),
+        ])
+
+        const root = lockAny?.packages?.[""] || {}
+        const locked = new Set([
+          ...Object.keys(root?.dependencies || {}),
+          ...Object.keys(root?.devDependencies || {}),
+          ...Object.keys(root?.peerDependencies || {}),
+          ...Object.keys(root?.optionalDependencies || {}),
+        ])
+
+        for (const name of declared) {
+          if (!locked.has(name)) {
+            yield* reify({ dir, add: input?.add })
+            return
+          }
+        }
+      }).pipe(Effect.withSpan("Npm.checkDirty"))
 
-  type PackageDeps = Record<string, string>
-  type PackageJson = {
-    dependencies?: PackageDeps
-    devDependencies?: PackageDeps
-    peerDependencies?: PackageDeps
-    optionalDependencies?: PackageDeps
-  }
-  const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
-  const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
-    packages?: Record<string, PackageJson>
-  }>(path.join(dir, "package-lock.json")).catch(() => ({}))
-
-  const declared = new Set([
-    ...Object.keys(pkg.dependencies || {}),
-    ...Object.keys(pkg.devDependencies || {}),
-    ...Object.keys(pkg.peerDependencies || {}),
-    ...Object.keys(pkg.optionalDependencies || {}),
-  ])
-
-  const root = lock.packages?.[""] || {}
-  const locked = new Set([
-    ...Object.keys(root.dependencies || {}),
-    ...Object.keys(root.devDependencies || {}),
-    ...Object.keys(root.peerDependencies || {}),
-    ...Object.keys(root.optionalDependencies || {}),
-  ])
-
-  for (const name of declared) {
-    if (!locked.has(name)) {
-      log.info("dependency not in lock file, reifying", { name })
-      await reify()
       return
-    }
-  }
+    }, Effect.scoped)
+
+    const which = Effect.fn("Npm.which")(function* (pkg: string) {
+      const dir = directory(pkg)
+      const binDir = path.join(dir, "node_modules", ".bin")
+
+      const pick = Effect.fnUntraced(function* () {
+        const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+
+        if (files.length === 0) return Option.none<string>()
+        if (files.length === 1) return Option.some(files[0])
+
+        const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
+
+        if (Option.isSome(pkgJson)) {
+          const parsed = pkgJson.value as { bin?: string | Record<string, string> }
+          if (parsed?.bin) {
+            const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
+            const bin = parsed.bin
+            if (typeof bin === "string") return Option.some(unscoped)
+            const keys = Object.keys(bin)
+            if (keys.length === 1) return Option.some(keys[0])
+            return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
+          }
+        }
+
+        return Option.some(files[0])
+      })
+
+      return yield* Effect.gen(function* () {
+        const bin = yield* pick()
+        if (Option.isSome(bin)) {
+          return Option.some(path.join(binDir, bin.value))
+        }
 
-  log.info("dependencies in sync")
+        yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
+
+        yield* add(pkg)
+
+        const resolved = yield* pick()
+        if (Option.isNone(resolved)) return Option.none<string>()
+        return Option.some(path.join(binDir, resolved.value))
+      }).pipe(
+        Effect.scoped,
+        Effect.orElseSucceed(() => Option.none<string>()),
+      )
+    })
+
+    return Service.of({
+      add,
+      install,
+      outdated,
+      which,
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(EffectFlock.layer),
+  Layer.provide(AppFileSystem.layer),
+  Layer.provide(Global.layer),
+  Layer.provide(NodeFileSystem.layer),
+)
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export async function install(...args: Parameters<Interface["install"]>) {
+  return runPromise((svc) => svc.install(...args))
 }
 
-export async function which(pkg: string) {
-  const dir = directory(pkg)
-  const binDir = path.join(dir, "node_modules", ".bin")
-
-  const pick = async () => {
-    const files = await readdir(binDir).catch(() => [])
-    if (files.length === 0) return undefined
-    if (files.length === 1) return files[0]
-    // Multiple binaries — resolve from package.json bin field like npx does
-    const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
-      path.join(dir, "node_modules", pkg, "package.json"),
-    ).catch(() => undefined)
-    if (pkgJson?.bin) {
-      const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
-      const bin = pkgJson.bin
-      if (typeof bin === "string") return unscoped
-      const keys = Object.keys(bin)
-      if (keys.length === 1) return keys[0]
-      return bin[unscoped] ? unscoped : keys[0]
-    }
-    return files[0]
+export async function add(...args: Parameters<Interface["add"]>) {
+  const entry = await runPromise((svc) => svc.add(...args))
+  return {
+    directory: entry.directory,
+    entrypoint: Option.getOrUndefined(entry.entrypoint),
   }
+}
 
-  const bin = await pick()
-  if (bin) return path.join(binDir, bin)
-
-  await rm(path.join(dir, "package-lock.json"), { force: true })
-  await add(pkg)
-  const resolved = await pick()
-  if (!resolved) return
-  return path.join(binDir, resolved)
+export async function outdated(...args: Parameters<Interface["outdated"]>) {
+  return runPromise((svc) => svc.outdated(...args))
 }
 
-export * as Npm from "."
+export async function which(...args: Parameters<Interface["which"]>) {
+  const resolved = await runPromise((svc) => svc.which(...args))
+  return Option.getOrUndefined(resolved)
+}

+ 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

+ 1 - 1
packages/opencode/src/plugin/shared.ts

@@ -4,7 +4,7 @@ import npa from "npm-package-arg"
 import semver from "semver"
 import { Filesystem } from "@/util"
 import { isRecord } from "@/util/record"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 // Old npm package names for plugins that are now built-in
 export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]

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

@@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
     family: model.family,
     api: {
       id: model.id,
-      url: model.provider?.api ?? provider.api!,
+      url: model.provider?.api ?? provider.api ?? "",
       npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
     },
     status: model.status ?? "active",
@@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
       output: model.limit.output,
     },
     capabilities: {
-      temperature: model.temperature,
-      reasoning: model.reasoning,
-      attachment: model.attachment,
-      toolcall: model.tool_call,
+      temperature: model.temperature ?? false,
+      reasoning: model.reasoning ?? false,
+      attachment: model.attachment ?? false,
+      toolcall: model.tool_call ?? true,
       input: {
         text: model.modalities?.input?.includes("text") ?? false,
         audio: model.modalities?.input?.includes("audio") ?? false,
@@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
       },
       interleaved: model.interleaved ?? false,
     },
-    release_date: model.release_date,
+    release_date: model.release_date ?? "",
     variants: {},
   }
 
@@ -1143,7 +1143,7 @@ const layer: Layer.Layer<
                   existingModel?.api.npm ??
                   modelsDev[providerID]?.npm ??
                   "@ai-sdk/openai-compatible",
-                url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
+                url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
               },
               status: model.status ?? existingModel?.status ?? "active",
               name,

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

+ 40 - 48
packages/opencode/src/server/routes/instance/index.ts

@@ -26,7 +26,8 @@ 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): Hono => {
   const app = new Hono()
@@ -140,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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",
@@ -177,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           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",
@@ -205,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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",
@@ -227,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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",
@@ -249,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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",
@@ -276,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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",
@@ -298,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
           },
         },
       }),
-      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
+        }),
     ),
 )

+ 35 - 0
packages/opencode/src/server/routes/instance/middleware.ts

@@ -0,0 +1,35 @@
+import type { MiddlewareHandler } from "hono"
+import { Instance } from "@/project/instance"
+import { InstanceBootstrap } from "@/project/bootstrap"
+import { AppRuntime } from "@/effect/app-runtime"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
+import { WorkspaceID } from "@/control-plane/schema"
+
+export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
+  return async (c, next) => {
+    const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+    const directory = AppFileSystem.resolve(
+      (() => {
+        try {
+          return decodeURIComponent(raw)
+        } catch {
+          return raw
+        }
+      })(),
+    )
+
+    return WorkspaceContext.provide({
+      workspaceID,
+      async fn() {
+        return Instance.provide({
+          directory,
+          init: () => AppRuntime.runPromise(InstanceBootstrap),
+          async fn() {
+            return next()
+          },
+        })
+      },
+    })
+  }
+}

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

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

+ 9 - 36
packages/opencode/src/server/server.ts

@@ -1,16 +1,10 @@
 import { generateSpecs } from "hono-openapi"
 import { Hono } from "hono"
-import type { MiddlewareHandler } from "hono"
 import { adapter } from "#hono"
 import { lazy } from "@/util/lazy"
 import { Log } from "@/util"
 import { Flag } from "@/flag/flag"
-import { Instance } from "@/project/instance"
-import { InstanceBootstrap } from "@/project/bootstrap"
-import { AppRuntime } from "@/effect/app-runtime"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { WorkspaceID } from "@/control-plane/schema"
-import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { MDNS } from "./mdns"
 import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
 import { FenceMiddleware } from "./fence"
@@ -20,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
@@ -48,34 +44,6 @@ function create(opts: { cors?: string[] }) {
 
   const runtime = adapter.create(app)
 
-  function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
-    return async (c, next) => {
-      const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
-      const directory = AppFileSystem.resolve(
-        (() => {
-          try {
-            return decodeURIComponent(raw)
-          } catch {
-            return raw
-          }
-        })(),
-      )
-
-      return WorkspaceContext.provide({
-        workspaceID,
-        async fn() {
-          return Instance.provide({
-            directory,
-            init: () => AppRuntime.runPromise(InstanceBootstrap),
-            async fn() {
-              return next()
-            },
-          })
-        },
-      })
-    }
-  }
-
   if (Flag.OPENCODE_WORKSPACE_ID) {
     return {
       app: app
@@ -88,9 +56,14 @@ function create(opts: { cors?: string[] }) {
 
   return {
     app: app
-      .use(InstanceMiddleware())
       .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
 }
 

+ 1 - 1
packages/opencode/src/session/processor.ts

@@ -213,7 +213,7 @@ export const layer: Layer.Layer<
         return true
       })
 
-      const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
+      const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
         switch (value.type) {
           case "start":
             yield* status.set(ctx.sessionID, { type: "busy" })

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

@@ -649,7 +649,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
       return input.partID
     })
 
-    const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
+    const updatePartDelta = Effect.fnUntraced(function* (input: {
       sessionID: SessionID
       messageID: MessageID
       partID: PartID

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

+ 32 - 4
packages/opencode/src/util/effect-zod.ts

@@ -16,13 +16,34 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
   const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
   if (override) return override
 
-  const out = body(ast)
+  let out = body(ast)
+  for (const check of ast.checks ?? []) {
+    out = applyCheck(out, check, ast)
+  }
   const desc = SchemaAST.resolveDescription(ast)
   const ref = SchemaAST.resolveIdentifier(ast)
   const next = desc ? out.describe(desc) : out
   return ref ? next.meta({ ref }) : next
 }
 
+function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
+  if (check._tag === "FilterGroup") {
+    return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
+  }
+  return out.superRefine((value, ctx) => {
+    const issue = check.run(value, ast, {} as any)
+    if (!issue) return
+    const message = issueMessage(issue) ?? (check.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 {
   if (SchemaAST.isOptional(ast)) return opt(ast)
 
@@ -98,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
 }
 
 function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
-  if (ast.elements.length > 0) return fail(ast)
-  if (ast.rest.length !== 1) return fail(ast)
-  return z.array(walk(ast.rest[0]))
+  // Pure variadic arrays: { elements: [], rest: [item] }
+  if (ast.elements.length === 0) {
+    if (ast.rest.length !== 1) return fail(ast)
+    return z.array(walk(ast.rest[0]))
+  }
+  // Fixed-length tuples: { elements: [a, b, ...], rest: [] }
+  // Tuples with a variadic tail (...rest) are not yet supported.
+  if (ast.rest.length > 0) return fail(ast)
+  const items = ast.elements.map(walk)
+  return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
 }
 
 function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {

+ 24 - 0
packages/opencode/src/util/opencode-process.ts

@@ -0,0 +1,24 @@
+export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
+export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
+
+export function ensureRunID() {
+  return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
+}
+
+export function ensureProcessRole(fallback: "main" | "worker") {
+  return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
+}
+
+export function ensureProcessMetadata(fallback: "main" | "worker") {
+  return {
+    runID: ensureRunID(),
+    processRole: ensureProcessRole(fallback),
+  }
+}
+
+export function sanitizedProcessEnv(overrides?: Record<string, string>) {
+  const env = Object.fromEntries(
+    Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
+  )
+  return overrides ? Object.assign(env, overrides) : env
+}

+ 6 - 6
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -117,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -179,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -241,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -299,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
   const warn = spyOn(console, "warn").mockImplementation(() => {})
   const error = spyOn(console, "error").mockImplementation(() => {})
 
@@ -468,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
   }
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
-  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+  const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
   try {
     await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })

+ 1 - 1
packages/opencode/test/config/config.test.ts

@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
 import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util"
 import { ConfigPlugin } from "@/config/plugin"
-import { Npm } from "@/npm/effect"
+import { Npm } from "@/npm"
 
 const emptyAccount = Layer.mock(Account.Service)({
   active: () => Effect.succeed(Option.none()),

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

+ 10 - 10
packages/opencode/test/plugin/loader-shared.test.ts

@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
     })
 
     const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
-      if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
-      return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
+      if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
+      return { directory: tmp.extra.scope, entrypoint: undefined }
     })
 
     try {
@@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
 
     try {
       await load(tmp.path)
@@ -927,7 +927,7 @@ export default {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
     const missing: string[] = []
 
     try {
@@ -996,7 +996,7 @@ export default {
       },
     })
 
-    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+    const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
 
     try {
       const loaded = await PluginLoader.loadExternal({

+ 33 - 1
packages/opencode/test/provider/provider.test.ts

@@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => {
         },
       },
     },
-  } as ModelsDev.Provider
+  } as unknown as ModelsDev.Provider
 
   const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
   expect(model.cost.input).toEqual(5)
@@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => {
   })
 })
 
+test("models.dev normalization fills required response fields", () => {
+  const provider = {
+    id: "gateway",
+    name: "Gateway",
+    env: [],
+    models: {
+      "gpt-5.4": {
+        id: "gpt-5.4",
+        name: "GPT-5.4",
+        family: "gpt",
+        cost: {
+          input: 2.5,
+          output: 15,
+        },
+        limit: {
+          context: 1_050_000,
+          input: 922_000,
+          output: 128_000,
+        },
+      },
+    },
+  } as unknown as ModelsDev.Provider
+
+  const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
+  expect(model.api.url).toBe("")
+  expect(model.capabilities.temperature).toBe(false)
+  expect(model.capabilities.reasoning).toBe(false)
+  expect(model.capabilities.attachment).toBe(false)
+  expect(model.capabilities.toolcall).toBe(true)
+  expect(model.release_date).toBe("")
+})
+
 test("model variants are generated for reasoning models", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 79 - 2
packages/opencode/test/util/effect-zod.test.ts

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

+ 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.10",
   "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.10",
   "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]

+ 24 - 30
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"
                 }
               }
             }
@@ -11428,13 +11411,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 +11435,7 @@
             "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
             "type": "string"
           }
-        },
-        "additionalProperties": false
+        }
       },
       "McpRemoteConfig": {
         "type": "object",
@@ -11498,13 +11477,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 +12342,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.10",
   "name": "@opencode-ai/shared",
   "type": "module",
   "license": "MIT",

+ 46 - 41
packages/shared/src/util/effect-flock.ts

@@ -165,55 +165,60 @@ export namespace EffectFlock {
 
       type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
 
-      const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) {
-        const token = randomUUID()
-        const metaPath = path.join(lockDir, "meta.json")
-        const heartbeatPath = path.join(lockDir, "heartbeat")
-
-        // Atomic mkdir — the POSIX lock primitive
-        const created = yield* atomicMkdir(lockDir)
-
-        if (!created) {
-          if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
-
-          // Stale — race for breaker ownership
-          const breakerPath = lockDir + ".breaker"
-
-          const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
-            Effect.as(true),
-            Effect.catchIf(
-              (e) => e.reason._tag === "AlreadyExists",
-              () => cleanStaleBreaker(breakerPath),
-            ),
-            Effect.catchIf(isPathGone, () => Effect.succeed(false)),
-            Effect.orDie,
-          )
-
-          if (!claimed) return yield* new NotAcquired()
-
-          // We own the breaker — double-check staleness, nuke, recreate
-          const recreated = yield* Effect.gen(function* () {
-            if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
-            yield* forceRemove(lockDir)
-            return yield* atomicMkdir(lockDir)
-          }).pipe(Effect.ensuring(forceRemove(breakerPath)))
+      const tryAcquireLockDir = (lockDir: string, key: string) =>
+        Effect.gen(function* () {
+          const token = randomUUID()
+          const metaPath = path.join(lockDir, "meta.json")
+          const heartbeatPath = path.join(lockDir, "heartbeat")
+
+          // Atomic mkdir — the POSIX lock primitive
+          const created = yield* atomicMkdir(lockDir)
+
+          if (!created) {
+            if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
+
+            // Stale — race for breaker ownership
+            const breakerPath = lockDir + ".breaker"
+
+            const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
+              Effect.as(true),
+              Effect.catchIf(
+                (e) => e.reason._tag === "AlreadyExists",
+                () => cleanStaleBreaker(breakerPath),
+              ),
+              Effect.catchIf(isPathGone, () => Effect.succeed(false)),
+              Effect.orDie,
+            )
+
+            if (!claimed) return yield* new NotAcquired()
+
+            // We own the breaker — double-check staleness, nuke, recreate
+            const recreated = yield* Effect.gen(function* () {
+              if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
+              yield* forceRemove(lockDir)
+              return yield* atomicMkdir(lockDir)
+            }).pipe(Effect.ensuring(forceRemove(breakerPath)))
 
-          if (!recreated) return yield* new NotAcquired()
-        }
+            if (!recreated) return yield* new NotAcquired()
+          }
 
-        // We own the lock dir — write heartbeat + meta with exclusive create
-        yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
+          // We own the lock dir — write heartbeat + meta with exclusive create
+          yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
 
-        const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
-        yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
+          const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
+          yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
 
-        return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
-      })
+          return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
+        }).pipe(
+          Effect.withSpan("EffectFlock.tryAcquire", {
+            attributes: { key },
+          }),
+        )
 
       // -- retry wrapper (preserves Handle type) --
 
       const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
-        tryAcquireLockDir(lockfile).pipe(
+        tryAcquireLockDir(lockfile, key).pipe(
           Effect.retry({
             while: (err) => err._tag === "NotAcquired",
             schedule: retrySchedule,

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/slack",
-  "version": "1.4.9",
+  "version": "1.4.10",
   "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.10",
   "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.10",
   "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.10",
   "publisher": "sst-dev",
   "repository": {
     "type": "git",