Просмотр исходного кода

Merge branch 'dev' into nxl/restore-native-ripgrep

Shoubhit Dash 1 день назад
Родитель
Сommit
1d29d4e0c6
100 измененных файлов с 7806 добавлено и 4520 удалено
  1. 0 1
      .opencode/agent/translator.md
  2. 1 5
      .opencode/opencode.jsonc
  3. 1 0
      AGENTS.md
  4. 60 39
      bun.lock
  5. 4 4
      nix/hashes.json
  6. 1 0
      nix/node_modules.nix
  7. 3 1
      package.json
  8. 1 1
      packages/app/package.json
  9. 61 44
      packages/app/src/components/session/session-header.tsx
  10. 74 0
      packages/app/src/components/settings-general.tsx
  11. 5 3
      packages/app/src/components/titlebar.tsx
  12. 1 2
      packages/app/src/context/global-sync.tsx
  13. 0 1
      packages/app/src/context/global-sync/child-store.ts
  14. 0 1
      packages/app/src/context/global-sync/types.ts
  15. 30 0
      packages/app/src/context/settings.tsx
  16. 2 4
      packages/app/src/env.d.ts
  17. 11 0
      packages/app/src/i18n/en.ts
  18. 180 182
      packages/app/src/pages/layout.tsx
  19. 5 7
      packages/app/src/pages/layout/sidebar-workspace.tsx
  20. 45 133
      packages/app/src/pages/session.tsx
  21. 97 84
      packages/app/src/pages/session/session-side-panel.tsx
  22. 18 6
      packages/app/src/pages/session/use-session-commands.tsx
  23. 1 1
      packages/app/src/utils/persist.ts
  24. 1 1
      packages/console/app/package.json
  25. 22 2
      packages/console/app/src/routes/zen/util/handler.ts
  26. 51 0
      packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
  27. 6 0
      packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql
  28. 2567 0
      packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json
  29. 1 1
      packages/console/core/package.json
  30. 11 4
      packages/console/core/src/model.ts
  31. 10 0
      packages/console/core/src/schema/ip.sql.ts
  32. 1 1
      packages/console/function/package.json
  33. 1 1
      packages/console/mail/package.json
  34. 1 1
      packages/desktop-electron/package.json
  35. 1 1
      packages/desktop/package.json
  36. 1 1
      packages/enterprise/package.json
  37. 6 6
      packages/extensions/zed/extension.toml
  38. 1 1
      packages/function/package.json
  39. 0 31
      packages/opencode/.opencode/package-lock.json
  40. 67 0
      packages/opencode/AGENTS.md
  41. 8 8
      packages/opencode/package.json
  42. 21 8
      packages/opencode/script/publish.ts
  43. 2 0
      packages/opencode/script/time.ts
  44. 0 0
      packages/opencode/script/trace-imports.ts
  45. 0 305
      packages/opencode/script/unwrap-namespace.ts
  46. 32 49
      packages/opencode/specs/effect/facades.md
  47. 85 46
      packages/opencode/specs/effect/http-api.md
  48. 10 10
      packages/opencode/specs/effect/instance-context.md
  49. 6 8
      packages/opencode/specs/effect/loose-ends.md
  50. 31 43
      packages/opencode/specs/effect/migration.md
  51. 0 499
      packages/opencode/specs/effect/namespace-treeshake.md
  52. 16 18
      packages/opencode/specs/effect/routes.md
  53. 254 28
      packages/opencode/specs/effect/schema.md
  54. 19 17
      packages/opencode/specs/effect/server-package.md
  55. 3 5
      packages/opencode/specs/effect/tools.md
  56. 4 2
      packages/opencode/src/account/account.ts
  57. 0 24
      packages/opencode/src/account/index.ts
  58. 140 140
      packages/opencode/src/account/repo.ts
  59. 1520 1523
      packages/opencode/src/acp/agent.ts
  60. 358 359
      packages/opencode/src/agent/agent.ts
  61. 0 89
      packages/opencode/src/auth/auth.ts
  62. 97 2
      packages/opencode/src/auth/index.ts
  63. 25 25
      packages/opencode/src/bus/bus-event.ts
  64. 0 191
      packages/opencode/src/bus/bus.ts
  65. 193 1
      packages/opencode/src/bus/index.ts
  66. 2 2
      packages/opencode/src/cli/cmd/account.ts
  67. 13 1
      packages/opencode/src/cli/cmd/generate.ts
  68. 5 4
      packages/opencode/src/cli/cmd/mcp.ts
  69. 3 1
      packages/opencode/src/cli/cmd/providers.ts
  70. 11 7
      packages/opencode/src/cli/cmd/tui/app.tsx
  71. 130 0
      packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx
  72. 1 0
      packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
  73. 104 46
      packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx
  74. 101 0
      packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx
  75. 99 13
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  76. 140 4
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  77. 81 0
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
  78. 321 61
      packages/opencode/src/cli/cmd/tui/component/logo.tsx
  79. 109 28
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  80. 4 3
      packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
  81. 1 1
      packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
  82. 169 158
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  83. 1 1
      packages/opencode/src/cli/cmd/tui/context/kv.tsx
  84. 3 1
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  85. 11 8
      packages/opencode/src/cli/cmd/tui/context/project.tsx
  86. 11 10
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  87. 27 1
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  88. 31 19
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  89. 1 1
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  90. 1 1
      packages/opencode/src/cli/cmd/tui/layer.ts
  91. 1 1
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  92. 102 90
      packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
  93. 2 3
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  94. 2 2
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
  95. 14 16
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
  96. 49 62
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  97. 24 1
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  98. 6 3
      packages/opencode/src/cli/cmd/tui/thread.ts
  99. 18 0
      packages/opencode/src/cli/cmd/tui/util/revert-diff.ts
  100. 35 1
      packages/opencode/src/cli/cmd/tui/util/signal.ts

+ 0 - 1
.opencode/agent/translator.md

@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
 OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
 OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
 OPENCODE_DISABLE_DEFAULT_PLUGINS
-OPENCODE_DISABLE_FILETIME_CHECK
 OPENCODE_DISABLE_LSP_DOWNLOAD
 OPENCODE_DISABLE_MODELS_FETCH
 OPENCODE_DISABLE_PRUNE

+ 1 - 5
.opencode/opencode.jsonc

@@ -1,10 +1,6 @@
 {
   "$schema": "https://opencode.ai/config.json",
-  "provider": {
-    "opencode": {
-      "options": {},
-    },
-  },
+  "provider": {},
   "permission": {
     "edit": {
       "packages/opencode/migration/*": "deny",

+ 1 - 0
AGENTS.md

@@ -14,6 +14,7 @@
 - Use Bun APIs when possible, like `Bun.file()`
 - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
 - Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
+- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
 
 Reduce total variable count by inlining when a value is only used once.
 

+ 60 - 39
bun.lock

@@ -29,7 +29,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
     },
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/shared": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -322,15 +322,15 @@
         "@actions/github": "6.0.1",
         "@agentclientprotocol/sdk": "0.16.1",
         "@ai-sdk/alibaba": "1.0.17",
-        "@ai-sdk/amazon-bedrock": "4.0.93",
-        "@ai-sdk/anthropic": "3.0.67",
+        "@ai-sdk/amazon-bedrock": "4.0.95",
+        "@ai-sdk/anthropic": "3.0.71",
         "@ai-sdk/azure": "3.0.49",
         "@ai-sdk/cerebras": "2.0.41",
         "@ai-sdk/cohere": "3.0.27",
         "@ai-sdk/deepinfra": "2.0.41",
-        "@ai-sdk/gateway": "3.0.97",
+        "@ai-sdk/gateway": "3.0.104",
         "@ai-sdk/google": "3.0.63",
-        "@ai-sdk/google-vertex": "4.0.109",
+        "@ai-sdk/google-vertex": "4.0.112",
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/mistral": "3.0.27",
         "@ai-sdk/openai": "3.0.53",
@@ -365,8 +365,8 @@
         "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
         "@opentelemetry/sdk-trace-base": "2.6.1",
         "@opentelemetry/sdk-trace-node": "2.6.1",
-        "@opentui/core": "0.1.99",
-        "@opentui/solid": "0.1.99",
+        "@opentui/core": "catalog:",
+        "@opentui/solid": "catalog:",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -386,7 +386,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.4.2",
+        "gitlab-ai-provider": "6.6.0",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -457,23 +457,23 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "effect": "catalog:",
         "zod": "catalog:",
       },
       "devDependencies": {
-        "@opentui/core": "0.1.99",
-        "@opentui/solid": "0.1.99",
+        "@opentui/core": "catalog:",
+        "@opentui/solid": "catalog:",
         "@tsconfig/node22": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
         "typescript": "catalog:",
       },
       "peerDependencies": {
-        "@opentui/core": ">=0.1.99",
-        "@opentui/solid": ">=0.1.99",
+        "@opentui/core": ">=0.1.100",
+        "@opentui/solid": ">=0.1.100",
       },
       "optionalPeers": [
         "@opentui/core",
@@ -492,7 +492,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -507,7 +507,7 @@
     },
     "packages/shared": {
       "name": "@opencode-ai/shared",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -515,6 +515,7 @@
         "@effect/platform-node": "catalog:",
         "@npmcli/arborist": "catalog:",
         "effect": "catalog:",
+        "glob": "13.0.5",
         "mime-types": "3.0.2",
         "minimatch": "10.2.5",
         "semver": "catalog:",
@@ -530,7 +531,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -565,7 +566,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -614,7 +615,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.4.6",
+      "version": "1.4.11",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -673,6 +674,8 @@
     "@npmcli/arborist": "9.4.0",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
+    "@opentui/core": "0.1.99",
+    "@opentui/solid": "0.1.99",
     "@pierre/diffs": "1.1.0-beta.18",
     "@playwright/test": "1.59.1",
     "@solid-primitives/storage": "4.3.3",
@@ -688,7 +691,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",
@@ -736,7 +739,7 @@
 
     "@ai-sdk/alibaba": ["@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-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
 
-    "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]3", "", { "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-sdk/amazon-bedrock": ["@ai-sdk/[email protected]5", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
 
     "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
 
@@ -756,11 +759,11 @@
 
     "@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].97", "", { "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-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="],
+    "@ai-sdk/gateway": ["@ai-sdk/[email protected].104", "", { "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=="],
 
-    "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]09", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/google": "3.0.63", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QzQ+DgOoSYlkU4mK0H+iaCaW1bl5zOimH9X2E2oylcVyUtAdCuduQ959Uw1ygW3l09J2K/ceEDtK8OUPHyOA7g=="],
+    "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]12", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
 
     "@ai-sdk/groq": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
 
@@ -1584,7 +1587,7 @@
 
     "@opentelemetry/otlp-transformer": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
 
-    "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+    "@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
 
     "@opentelemetry/sdk-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
 
@@ -2452,7 +2455,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=="],
 
@@ -2512,7 +2515,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=="],
 
@@ -3310,7 +3313,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
+    "gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
@@ -5148,7 +5151,11 @@
 
     "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@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-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
-    "@ai-sdk/amazon-bedrock/@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=="],
+    "@ai-sdk/amazon-bedrock/@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-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
+
+    "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
+
+    "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
 
     "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
@@ -5162,7 +5169,9 @@
 
     "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@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-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
-    "@ai-sdk/google-vertex/@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=="],
+    "@ai-sdk/google-vertex/@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-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
+
+    "@ai-sdk/google-vertex/@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-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
 
     "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@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-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
 
@@ -5572,6 +5581,18 @@
 
     "@opencode-ai/web/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
 
+    "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+    "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+    "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
+
+    "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+    "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+    "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
     "@opentui/solid/@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
 
     "@opentui/solid/babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
@@ -5678,7 +5699,7 @@
 
     "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=="],
 
@@ -5896,7 +5917,7 @@
 
     "nypm/tinyexec": ["[email protected]", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
 
-    "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected].67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
+    "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]1", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
 
     "opencode/@ai-sdk/openai": ["@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-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
 

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=",
-    "aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=",
-    "aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=",
-    "x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU="
+    "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 - 0
nix/node_modules.nix

@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
       --filter './packages/opencode' \
       --filter './packages/desktop' \
       --filter './packages/app' \
+      --filter './packages/shared' \
       --frozen-lockfile \
       --ignore-scripts \
       --no-progress

+ 3 - 1
package.json

@@ -34,6 +34,8 @@
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
+      "@opentui/core": "0.1.99",
+      "@opentui/solid": "0.1.99",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@types/luxon": "3.7.1",
@@ -51,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.6",
+  "version": "1.4.11",
   "description": "",
   "type": "module",
   "exports": {

+ 61 - 44
packages/app/src/components/session/session-header.tsx

@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
 import { showToast } from "@opencode-ai/ui/toast"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { getFilename } from "@opencode-ai/shared/util/path"
-import { createEffect, createMemo, For, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { useCommand } from "@/context/command"
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useServer } from "@/context/server"
+import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { focusTerminalById } from "@/pages/session/helpers"
@@ -134,6 +135,7 @@ export function SessionHeader() {
   const server = useServer()
   const platform = usePlatform()
   const language = useLanguage()
+  const settings = useSettings()
   const sync = useSync()
   const terminal = useTerminal()
   const { params, view } = useSessionLayout()
@@ -151,6 +153,11 @@ export function SessionHeader() {
   })
   const hotkey = createMemo(() => command.keybind("file.open"))
   const os = createMemo(() => detectOS(platform))
+  const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
+  const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
+  const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
+  const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
+  const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
 
   const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
     finder: true,
@@ -262,12 +269,16 @@ export function SessionHeader() {
       .catch((err: unknown) => showRequestError(language, err))
   }
 
-  const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
-  const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
+  const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
+  const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
+  onMount(() => {
+    setCenterMount(document.getElementById("opencode-titlebar-center"))
+    setRightMount(document.getElementById("opencode-titlebar-right"))
+  })
 
   return (
     <>
-      <Show when={centerMount()}>
+      <Show when={search() && centerMount()}>
         {(mount) => (
           <Portal mount={mount()}>
             <Button
@@ -415,24 +426,28 @@ export function SessionHeader() {
                 </div>
               </Show>
               <div class="flex items-center gap-1">
-                <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
-                  <StatusPopover />
-                </Tooltip>
-                <TooltipKeybind
-                  title={language.t("command.terminal.toggle")}
-                  keybind={command.keybind("terminal.toggle")}
-                >
-                  <Button
-                    variant="ghost"
-                    class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
-                    onClick={toggleTerminal}
-                    aria-label={language.t("command.terminal.toggle")}
-                    aria-expanded={view().terminal.opened()}
-                    aria-controls="terminal-panel"
+                <Show when={status()}>
+                  <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
+                    <StatusPopover />
+                  </Tooltip>
+                </Show>
+                <Show when={term()}>
+                  <TooltipKeybind
+                    title={language.t("command.terminal.toggle")}
+                    keybind={command.keybind("terminal.toggle")}
                   >
-                    <Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
-                  </Button>
-                </TooltipKeybind>
+                    <Button
+                      variant="ghost"
+                      class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
+                      onClick={toggleTerminal}
+                      aria-label={language.t("command.terminal.toggle")}
+                      aria-expanded={view().terminal.opened()}
+                      aria-controls="terminal-panel"
+                    >
+                      <Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
+                    </Button>
+                  </TooltipKeybind>
+                </Show>
 
                 <div class="hidden md:flex items-center gap-1 shrink-0">
                   <TooltipKeybind
@@ -451,30 +466,32 @@ export function SessionHeader() {
                     </Button>
                   </TooltipKeybind>
 
-                  <TooltipKeybind
-                    title={language.t("command.fileTree.toggle")}
-                    keybind={command.keybind("fileTree.toggle")}
-                  >
-                    <Button
-                      variant="ghost"
-                      class="titlebar-icon w-8 h-6 p-0 box-border"
-                      onClick={() => layout.fileTree.toggle()}
-                      aria-label={language.t("command.fileTree.toggle")}
-                      aria-expanded={layout.fileTree.opened()}
-                      aria-controls="file-tree-panel"
+                  <Show when={tree()}>
+                    <TooltipKeybind
+                      title={language.t("command.fileTree.toggle")}
+                      keybind={command.keybind("fileTree.toggle")}
                     >
-                      <div class="relative flex items-center justify-center size-4">
-                        <Icon
-                          size="small"
-                          name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
-                          classList={{
-                            "text-icon-strong": layout.fileTree.opened(),
-                            "text-icon-weak": !layout.fileTree.opened(),
-                          }}
-                        />
-                      </div>
-                    </Button>
-                  </TooltipKeybind>
+                      <Button
+                        variant="ghost"
+                        class="titlebar-icon w-8 h-6 p-0 box-border"
+                        onClick={() => layout.fileTree.toggle()}
+                        aria-label={language.t("command.fileTree.toggle")}
+                        aria-expanded={layout.fileTree.opened()}
+                        aria-controls="file-tree-panel"
+                      >
+                        <div class="relative flex items-center justify-center size-4">
+                          <Icon
+                            size="small"
+                            name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
+                            classList={{
+                              "text-icon-strong": layout.fileTree.opened(),
+                              "text-icon-weak": !layout.fileTree.opened(),
+                            }}
+                          />
+                        </div>
+                      </Button>
+                    </TooltipKeybind>
+                  </Show>
                 </div>
               </div>
             </div>

+ 74 - 0
packages/app/src/components/settings-general.tsx

@@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
 
     permission.disableAutoAccept(params.id, value)
   }
+  const desktop = createMemo(() => platform.platform === "desktop")
 
   const check = () => {
     if (!platform.checkUpdate) return
@@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
     </div>
   )
 
+  const AdvancedSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
+
+      <SettingsList>
+        <SettingsRow
+          title={language.t("settings.general.row.showFileTree.title")}
+          description={language.t("settings.general.row.showFileTree.description")}
+        >
+          <div data-action="settings-show-file-tree">
+            <Switch
+              checked={settings.general.showFileTree()}
+              onChange={(checked) => settings.general.setShowFileTree(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.showNavigation.title")}
+          description={language.t("settings.general.row.showNavigation.description")}
+        >
+          <div data-action="settings-show-navigation">
+            <Switch
+              checked={settings.general.showNavigation()}
+              onChange={(checked) => settings.general.setShowNavigation(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.showSearch.title")}
+          description={language.t("settings.general.row.showSearch.description")}
+        >
+          <div data-action="settings-show-search">
+            <Switch
+              checked={settings.general.showSearch()}
+              onChange={(checked) => settings.general.setShowSearch(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.showTerminal.title")}
+          description={language.t("settings.general.row.showTerminal.description")}
+        >
+          <div data-action="settings-show-terminal">
+            <Switch
+              checked={settings.general.showTerminal()}
+              onChange={(checked) => settings.general.setShowTerminal(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.showStatus.title")}
+          description={language.t("settings.general.row.showStatus.description")}
+        >
+          <div data-action="settings-show-status">
+            <Switch
+              checked={settings.general.showStatus()}
+              onChange={(checked) => settings.general.setShowStatus(checked)}
+            />
+          </div>
+        </SettingsRow>
+      </SettingsList>
+    </div>
+  )
+
   const AppearanceSection = () => (
     <div class="flex flex-col gap-1">
       <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
@@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
     </div>
   )
 
+  console.log(import.meta.env)
   return (
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => {
             )
           }}
         </Show>
+
+        <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
+          <AdvancedSection />
+        </Show>
       </div>
     </div>
   )

+ 5 - 3
packages/app/src/components/titlebar.tsx

@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
+import { useSettings } from "@/context/settings"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
 
 type TauriDesktopWindow = {
@@ -40,6 +41,7 @@ export function Titlebar() {
   const platform = usePlatform()
   const command = useCommand()
   const language = useLanguage()
+  const settings = useSettings()
   const theme = useTheme()
   const navigate = useNavigate()
   const location = useLocation()
@@ -78,6 +80,7 @@ export function Titlebar() {
   const canBack = createMemo(() => history.index > 0)
   const canForward = createMemo(() => history.index < history.stack.length - 1)
   const hasProjects = createMemo(() => layout.projects.list().length > 0)
+  const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
 
   const back = () => {
     const next = backPath(history)
@@ -255,13 +258,12 @@ export function Titlebar() {
             <div
               class="flex items-center shrink-0"
               classList={{
-                "translate-x-0": !layout.sidebar.opened(),
-                "-translate-x-[36px]": layout.sidebar.opened(),
+                "-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
                 "duration-180 ease-out": !layout.sidebar.opened(),
                 "duration-180 ease-in": layout.sidebar.opened(),
               }}
             >
-              <Show when={hasProjects()}>
+              <Show when={hasProjects() && nav()}>
                 <div class="flex items-center gap-0 transition-transform">
                   <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
                     <Button

+ 1 - 2
packages/app/src/context/global-sync.tsx

@@ -204,7 +204,7 @@ function createGlobalSync() {
 
     const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
     const promise = queryClient
-      .ensureQueryData({
+      .fetchQuery({
         ...loadSessionsQuery(directory),
         queryFn: () =>
           loadRootSessionsWithFallback({
@@ -264,7 +264,6 @@ function createGlobalSync() {
     children.pin(directory)
     const promise = Promise.resolve().then(async () => {
       const child = children.ensureChild(directory)
-      child[1]("bootstrapPromise", promise!)
       const cache = children.vcsCache.get(directory)
       if (!cache) return
       const sdk = sdkFor(directory)

+ 0 - 1
packages/app/src/context/global-sync/child-store.ts

@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
             limit: 5,
             message: {},
             part: {},
-            bootstrapPromise: Promise.resolve(),
           })
           children[directory] = child
           disposers.set(directory, dispose)

+ 0 - 1
packages/app/src/context/global-sync/types.ts

@@ -72,7 +72,6 @@ export type State = {
   part: {
     [messageID: string]: Part[]
   }
-  bootstrapPromise: Promise<void>
 }
 
 export type VcsCache = {

+ 30 - 0
packages/app/src/context/settings.tsx

@@ -23,6 +23,11 @@ export interface Settings {
     autoSave: boolean
     releaseNotes: boolean
     followup: "queue" | "steer"
+    showFileTree: boolean
+    showNavigation: boolean
+    showSearch: boolean
+    showStatus: boolean
+    showTerminal: boolean
     showReasoningSummaries: boolean
     shellToolPartsExpanded: boolean
     editToolPartsExpanded: boolean
@@ -89,6 +94,11 @@ const defaultSettings: Settings = {
     autoSave: true,
     releaseNotes: true,
     followup: "steer",
+    showFileTree: false,
+    showNavigation: false,
+    showSearch: false,
+    showStatus: false,
+    showTerminal: false,
     showReasoningSummaries: false,
     shellToolPartsExpanded: false,
     editToolPartsExpanded: false,
@@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setFollowup(value: "queue" | "steer") {
           setStore("general", "followup", value === "queue" ? "steer" : value)
         },
+        showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
+        setShowFileTree(value: boolean) {
+          setStore("general", "showFileTree", value)
+        },
+        showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
+        setShowNavigation(value: boolean) {
+          setStore("general", "showNavigation", value)
+        },
+        showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
+        setShowSearch(value: boolean) {
+          setStore("general", "showSearch", value)
+        },
+        showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
+        setShowStatus(value: boolean) {
+          setStore("general", "showStatus", value)
+        },
+        showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
+        setShowTerminal(value: boolean) {
+          setStore("general", "showTerminal", value)
+        },
         showReasoningSummaries: withFallback(
           () => store.general?.showReasoningSummaries,
           defaultSettings.general.showReasoningSummaries,

+ 2 - 4
packages/app/src/env.d.ts

@@ -1,16 +1,14 @@
-import "solid-js"
-
 interface ImportMetaEnv {
   readonly VITE_OPENCODE_SERVER_HOST: string
   readonly VITE_OPENCODE_SERVER_PORT: string
-  readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
+  readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
 }
 
 interface ImportMeta {
   readonly env: ImportMetaEnv
 }
 
-declare module "solid-js" {
+export declare module "solid-js" {
   namespace JSX {
     interface Directives {
       sortable: true

+ 11 - 0
packages/app/src/i18n/en.ts

@@ -719,6 +719,7 @@ export const dict = {
   "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
 
   "settings.general.section.appearance": "Appearance",
+  "settings.general.section.advanced": "Advanced",
   "settings.general.section.notifications": "System notifications",
   "settings.general.section.updates": "Updates",
   "settings.general.section.sounds": "Sound effects",
@@ -741,6 +742,16 @@ export const dict = {
   "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
   "settings.general.row.followup.option.queue": "Queue",
   "settings.general.row.followup.option.steer": "Steer",
+  "settings.general.row.showFileTree.title": "File tree",
+  "settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
+  "settings.general.row.showNavigation.title": "Navigation controls",
+  "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
+  "settings.general.row.showSearch.title": "Command palette",
+  "settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
+  "settings.general.row.showTerminal.title": "Terminal",
+  "settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
+  "settings.general.row.showStatus.title": "Server status",
+  "settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
   "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
   "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
   "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

+ 180 - 182
packages/app/src/pages/layout.tsx

@@ -13,7 +13,7 @@ import {
   type Accessor,
 } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
   const theme = useTheme()
   const language = useLanguage()
   const initialDirectory = decode64(params.dir)
+  const location = useLocation()
   const route = createMemo(() => {
     const slug = params.dir
     if (!slug) return { slug, dir: "" }
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
             </Show>
           }
         >
-          <>
-            <div class="shrink-0 pl-1 py-1">
-              <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
-                <div class="flex flex-col min-w-0">
-                  <InlineEditor
-                    id={`project:${projectId()}`}
-                    value={projectName}
-                    onSave={(next) => {
-                      const item = project()
-                      if (!item) return
-                      void renameProject(item, next)
-                    }}
-                    class="text-14-medium text-text-strong truncate"
-                    displayClass="text-14-medium text-text-strong truncate"
-                    stopPropagation
-                  />
-
-                  <Tooltip
-                    placement="bottom"
-                    gutter={2}
-                    value={worktree()}
-                    class="shrink-0"
-                    contentStyle={{
-                      "max-width": "640px",
-                      transform: "translate3d(52px, 0, 0)",
-                    }}
-                  >
-                    <span class="text-12-regular text-text-base truncate select-text">
-                      {worktree().replace(homedir(), "~")}
-                    </span>
-                  </Tooltip>
-                </div>
+          {(project) => (
+            <>
+              <div class="shrink-0 pl-1 py-1">
+                <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
+                  <div class="flex flex-col min-w-0">
+                    <InlineEditor
+                      id={`project:${projectId()}`}
+                      value={projectName}
+                      onSave={(next) => {
+                        const item = project()
+                        if (!item) return
+                        void renameProject(item, next)
+                      }}
+                      class="text-14-medium text-text-strong truncate"
+                      displayClass="text-14-medium text-text-strong truncate"
+                      stopPropagation
+                    />
+
+                    <Tooltip
+                      placement="bottom"
+                      gutter={2}
+                      value={worktree()}
+                      class="shrink-0"
+                      contentStyle={{
+                        "max-width": "640px",
+                        transform: "translate3d(52px, 0, 0)",
+                      }}
+                    >
+                      <span class="text-12-regular text-text-base truncate select-text">
+                        {worktree().replace(homedir(), "~")}
+                      </span>
+                    </Tooltip>
+                  </div>
 
-                <DropdownMenu modal={!sidebarHovering()}>
-                  <DropdownMenu.Trigger
-                    as={IconButton}
-                    icon="dot-grid"
-                    variant="ghost"
-                    data-action="project-menu"
-                    data-project={slug()}
-                    class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
-                    classList={{
-                      "opacity-100": panelProps.mobile || merged(),
-                      "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
-                        !panelProps.mobile && !merged(),
-                    }}
-                    aria-label={language.t("common.moreOptions")}
-                  />
-                  <DropdownMenu.Portal>
-                    <DropdownMenu.Content class="mt-1">
-                      <DropdownMenu.Item
-                        onSelect={() => {
-                          const item = project()
-                          if (!item) return
-                          showEditProjectDialog(item)
-                        }}
-                      >
-                        <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                      <DropdownMenu.Item
-                        data-action="project-workspaces-toggle"
-                        data-project={slug()}
-                        disabled={!canToggle()}
-                        onSelect={() => {
-                          const item = project()
-                          if (!item) return
-                          toggleProjectWorkspaces(item)
-                        }}
-                      >
-                        <DropdownMenu.ItemLabel>
-                          {workspacesEnabled()
-                            ? language.t("sidebar.workspaces.disable")
-                            : language.t("sidebar.workspaces.enable")}
-                        </DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                      <DropdownMenu.Item
-                        data-action="project-clear-notifications"
-                        data-project={slug()}
-                        disabled={unseenCount() === 0}
-                        onSelect={clearNotifications}
-                      >
-                        <DropdownMenu.ItemLabel>
-                          {language.t("sidebar.project.clearNotifications")}
-                        </DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                      <DropdownMenu.Separator />
-                      <DropdownMenu.Item
-                        data-action="project-close-menu"
-                        data-project={slug()}
-                        onSelect={() => {
-                          const dir = worktree()
-                          if (!dir) return
-                          closeProject(dir)
-                        }}
-                      >
-                        <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                    </DropdownMenu.Content>
-                  </DropdownMenu.Portal>
-                </DropdownMenu>
+                  <DropdownMenu modal={!sidebarHovering()}>
+                    <DropdownMenu.Trigger
+                      as={IconButton}
+                      icon="dot-grid"
+                      variant="ghost"
+                      data-action="project-menu"
+                      data-project={slug()}
+                      class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
+                      classList={{
+                        "opacity-100": panelProps.mobile || merged(),
+                        "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
+                          !panelProps.mobile && !merged(),
+                      }}
+                      aria-label={language.t("common.moreOptions")}
+                    />
+                    <DropdownMenu.Portal>
+                      <DropdownMenu.Content class="mt-1">
+                        <DropdownMenu.Item
+                          onSelect={() => {
+                            const item = project()
+                            if (!item) return
+                            showEditProjectDialog(item)
+                          }}
+                        >
+                          <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Item
+                          data-action="project-workspaces-toggle"
+                          data-project={slug()}
+                          disabled={!canToggle()}
+                          onSelect={() => {
+                            const item = project()
+                            if (!item) return
+                            toggleProjectWorkspaces(item)
+                          }}
+                        >
+                          <DropdownMenu.ItemLabel>
+                            {workspacesEnabled()
+                              ? language.t("sidebar.workspaces.disable")
+                              : language.t("sidebar.workspaces.enable")}
+                          </DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Item
+                          data-action="project-clear-notifications"
+                          data-project={slug()}
+                          disabled={unseenCount() === 0}
+                          onSelect={clearNotifications}
+                        >
+                          <DropdownMenu.ItemLabel>
+                            {language.t("sidebar.project.clearNotifications")}
+                          </DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                        <DropdownMenu.Separator />
+                        <DropdownMenu.Item
+                          data-action="project-close-menu"
+                          data-project={slug()}
+                          onSelect={() => {
+                            const dir = worktree()
+                            if (!dir) return
+                            closeProject(dir)
+                          }}
+                        >
+                          <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                      </DropdownMenu.Content>
+                    </DropdownMenu.Portal>
+                  </DropdownMenu>
+                </div>
               </div>
-            </div>
 
-            <div class="flex-1 min-h-0 flex flex-col">
-              <Show
-                when={workspacesEnabled()}
-                fallback={
+              <div class="flex-1 min-h-0 flex flex-col">
+                <Show
+                  when={workspacesEnabled()}
+                  fallback={
+                    <>
+                      <div class="shrink-0 py-4">
+                        <Button
+                          size="large"
+                          icon="new-session"
+                          class="w-full"
+                          onClick={() => {
+                            const dir = worktree()
+                            if (!dir) return
+                            navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+                          }}
+                        >
+                          {language.t("command.session.new")}
+                        </Button>
+                      </div>
+                      <div class="flex-1 min-h-0">
+                        <LocalWorkspace
+                          ctx={workspaceSidebarCtx}
+                          project={project()}
+                          sortNow={sortNow}
+                          mobile={panelProps.mobile}
+                        />
+                      </div>
+                    </>
+                  }
+                >
                   <>
                     <div class="shrink-0 py-4">
                       <Button
                         size="large"
-                        icon="new-session"
+                        icon="plus-small"
                         class="w-full"
                         onClick={() => {
-                          const dir = worktree()
-                          if (!dir) return
-                          navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+                          const item = project()
+                          if (!item) return
+                          void createWorkspace(item)
                         }}
                       >
-                        {language.t("command.session.new")}
+                        {language.t("workspace.new")}
                       </Button>
                     </div>
-                    <div class="flex-1 min-h-0">
-                      <LocalWorkspace
-                        ctx={workspaceSidebarCtx}
-                        project={project()!}
-                        sortNow={sortNow}
-                        mobile={panelProps.mobile}
-                      />
+                    <div class="relative flex-1 min-h-0">
+                      <DragDropProvider
+                        onDragStart={handleWorkspaceDragStart}
+                        onDragEnd={handleWorkspaceDragEnd}
+                        onDragOver={handleWorkspaceDragOver}
+                        collisionDetector={closestCenter}
+                      >
+                        <DragDropSensors />
+                        <ConstrainDragXAxis />
+                        <div
+                          ref={(el) => {
+                            if (!panelProps.mobile) scrollContainerRef = el
+                          }}
+                          class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+                        >
+                          <SortableProvider ids={workspaces()}>
+                            <For each={workspaces()}>
+                              {(directory) => (
+                                <SortableWorkspace
+                                  ctx={workspaceSidebarCtx}
+                                  directory={directory}
+                                  project={project()}
+                                  sortNow={sortNow}
+                                  mobile={panelProps.mobile}
+                                />
+                              )}
+                            </For>
+                          </SortableProvider>
+                        </div>
+                        <DragOverlay>
+                          <WorkspaceDragOverlay
+                            sidebarProject={sidebarProject}
+                            activeWorkspace={() => store.activeWorkspace}
+                            workspaceLabel={workspaceLabel}
+                          />
+                        </DragOverlay>
+                      </DragDropProvider>
                     </div>
                   </>
-                }
-              >
-                <>
-                  <div class="shrink-0 py-4">
-                    <Button
-                      size="large"
-                      icon="plus-small"
-                      class="w-full"
-                      onClick={() => {
-                        const item = project()
-                        if (!item) return
-                        void createWorkspace(item)
-                      }}
-                    >
-                      {language.t("workspace.new")}
-                    </Button>
-                  </div>
-                  <div class="relative flex-1 min-h-0">
-                    <DragDropProvider
-                      onDragStart={handleWorkspaceDragStart}
-                      onDragEnd={handleWorkspaceDragEnd}
-                      onDragOver={handleWorkspaceDragOver}
-                      collisionDetector={closestCenter}
-                    >
-                      <DragDropSensors />
-                      <ConstrainDragXAxis />
-                      <div
-                        ref={(el) => {
-                          if (!panelProps.mobile) scrollContainerRef = el
-                        }}
-                        class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
-                      >
-                        <SortableProvider ids={workspaces()}>
-                          <For each={workspaces()}>
-                            {(directory) => (
-                              <SortableWorkspace
-                                ctx={workspaceSidebarCtx}
-                                directory={directory}
-                                project={project()!}
-                                sortNow={sortNow}
-                                mobile={panelProps.mobile}
-                              />
-                            )}
-                          </For>
-                        </SortableProvider>
-                      </div>
-                      <DragOverlay>
-                        <WorkspaceDragOverlay
-                          sidebarProject={sidebarProject}
-                          activeWorkspace={() => store.activeWorkspace}
-                          workspaceLabel={workspaceLabel}
-                        />
-                      </DragOverlay>
-                    </DragDropProvider>
-                  </div>
-                </>
-              </Show>
-            </div>
-          </>
+                </Show>
+              </div>
+            </>
+          )}
         </Show>
 
         <div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
     />
   )
 
-  const [loading] = createResource(
-    () => route()?.store?.[0]?.bootstrapPromise,
-    (p) => p,
-  )
-
   return (
     <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
-      {(autoselecting(), loading()) ?? ""}
+      {autoselecting() ?? ""}
       <Titlebar />
       <div class="flex-1 min-h-0 min-w-0 flex">
         <div class="flex-1 min-h-0 relative">

+ 5 - 7
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
   })
   const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
   const boot = createMemo(() => open() || active())
-  const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
   const count = createMemo(() => sessions()?.length ?? 0)
   const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
+  const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
   const busy = createMemo(() => props.ctx.isBusy(props.directory))
-  const wasBusy = createMemo((prev) => prev || busy(), false)
-  const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
+  const loading = () => query.isLoading
   const touch = createMediaQuery("(hover: none)")
   const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
   const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
             mobile={props.mobile}
             ctx={props.ctx}
             showNew={showNew}
-            loading={loading}
+            loading={() => query.isLoading && count() === 0}
             sessions={sessions}
             hasMore={hasMore}
             loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
   })
   const slug = createMemo(() => base64Encode(props.project.worktree))
   const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
-  const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
   const count = createMemo(() => sessions()?.length ?? 0)
   const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
-  const loading = createMemo(() => query.isPending && count() === 0)
   const hasMore = createMemo(() => workspace().store.sessionTotal > count())
+  const loading = () => query.isLoading && count() === 0
   const loadMore = async () => {
     workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
     await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
         mobile={props.mobile}
         ctx={props.ctx}
         showNew={() => false}
-        loading={() => query.isLoading}
+        loading={loading}
         sessions={sessions}
         hasMore={hasMore}
         loadMore={loadMore}

+ 45 - 133
packages/app/src/pages/session.tsx

@@ -1,6 +1,6 @@
-import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useMutation } from "@tanstack/solid-query"
+import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
 import {
   batch,
   onCleanup,
@@ -324,6 +324,7 @@ export default function Page() {
   const local = useLocal()
   const file = useFile()
   const sync = useSync()
+  const queryClient = useQueryClient()
   const dialog = useDialog()
   const language = useLanguage()
   const sdk = useSDK()
@@ -518,26 +519,6 @@ export default function Page() {
     deferRender: false,
   })
 
-  const [vcs, setVcs] = createStore<{
-    diff: {
-      git: VcsFileDiff[]
-      branch: VcsFileDiff[]
-    }
-    ready: {
-      git: boolean
-      branch: boolean
-    }
-  }>({
-    diff: {
-      git: [] as VcsFileDiff[],
-      branch: [] as VcsFileDiff[],
-    },
-    ready: {
-      git: false,
-      branch: false,
-    },
-  })
-
   const [followup, setFollowup] = persisted(
     Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
     createStore<{
@@ -571,68 +552,6 @@ export default function Page() {
   let todoTimer: number | undefined
   let diffFrame: number | undefined
   let diffTimer: number | undefined
-  const vcsTask = new Map<VcsMode, Promise<void>>()
-  const vcsRun = new Map<VcsMode, number>()
-
-  const bumpVcs = (mode: VcsMode) => {
-    const next = (vcsRun.get(mode) ?? 0) + 1
-    vcsRun.set(mode, next)
-    return next
-  }
-
-  const resetVcs = (mode?: VcsMode) => {
-    const list = mode ? [mode] : (["git", "branch"] as const)
-    list.forEach((item) => {
-      bumpVcs(item)
-      vcsTask.delete(item)
-      setVcs("diff", item, [])
-      setVcs("ready", item, false)
-    })
-  }
-
-  const loadVcs = (mode: VcsMode, force = false) => {
-    if (sync.project?.vcs !== "git") return Promise.resolve()
-    if (!force && vcs.ready[mode]) return Promise.resolve()
-
-    if (force) {
-      if (vcsTask.has(mode)) bumpVcs(mode)
-      vcsTask.delete(mode)
-      setVcs("ready", mode, false)
-    }
-
-    const current = vcsTask.get(mode)
-    if (current) return current
-
-    const run = bumpVcs(mode)
-
-    const task = sdk.client.vcs
-      .diff({ mode })
-      .then((result) => {
-        if (vcsRun.get(mode) !== run) return
-        setVcs("diff", mode, list(result.data))
-        setVcs("ready", mode, true)
-      })
-      .catch((error) => {
-        if (vcsRun.get(mode) !== run) return
-        console.debug("[session-review] failed to load vcs diff", { mode, error })
-        setVcs("diff", mode, [])
-        setVcs("ready", mode, true)
-      })
-      .finally(() => {
-        if (vcsTask.get(mode) === task) vcsTask.delete(mode)
-      })
-
-    vcsTask.set(mode, task)
-    return task
-  }
-
-  const refreshVcs = () => {
-    resetVcs()
-    const mode = untrack(vcsMode)
-    if (!mode) return
-    if (!untrack(wantsReview)) return
-    void loadVcs(mode, true)
-  }
 
   createComputed((prev) => {
     const open = desktopReviewOpen()
@@ -663,21 +582,52 @@ export default function Page() {
     list.push("turn")
     return list
   })
+  const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
+  const wantsReview = createMemo(() =>
+    isDesktop()
+      ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
+      : store.mobileTab === "changes",
+  )
   const vcsMode = createMemo<VcsMode | undefined>(() => {
     if (store.changes === "git" || store.changes === "branch") return store.changes
   })
-  const reviewDiffs = createMemo(() => {
-    if (store.changes === "git") return list(vcs.diff.git)
-    if (store.changes === "branch") return list(vcs.diff.branch)
-    return turnDiffs()
+  const vcsKey = createMemo(
+    () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
+  )
+  const vcsQuery = createQuery(() => {
+    const mode = vcsMode()
+    const enabled = wantsReview() && sync.project?.vcs === "git"
+
+    return {
+      queryKey: [...vcsKey(), mode] as const,
+      enabled,
+      staleTime: Number.POSITIVE_INFINITY,
+      gcTime: 60 * 1000,
+      queryFn: mode
+        ? () =>
+            sdk.client.vcs
+              .diff({ mode })
+              .then((result) => list(result.data))
+              .catch((error) => {
+                console.debug("[session-review] failed to load vcs diff", { mode, error })
+                return []
+              })
+        : skipToken,
+    }
   })
-  const reviewCount = createMemo(() => reviewDiffs().length)
-  const hasReview = createMemo(() => reviewCount() > 0)
-  const reviewReady = createMemo(() => {
-    if (store.changes === "git") return vcs.ready.git
-    if (store.changes === "branch") return vcs.ready.branch
+  const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
+  const reviewDiffs = () => {
+    if (store.changes === "git" || store.changes === "branch")
+      // avoids suspense
+      return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
+    return turnDiffs()
+  }
+  const reviewCount = () => reviewDiffs().length
+  const hasReview = () => reviewCount() > 0
+  const reviewReady = () => {
+    if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
     return true
-  })
+  }
 
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
@@ -897,27 +847,6 @@ export default function Page() {
     ),
   )
 
-  createEffect(
-    on(
-      () => sdk.directory,
-      () => {
-        resetVcs()
-      },
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
-      (next, prev) => {
-        if (prev === undefined || same(next, prev)) return
-        refreshVcs()
-      },
-      { defer: true },
-    ),
-  )
-
   const stopVcs = sdk.event.listen((evt) => {
     if (evt.details.type !== "file.watcher.updated") return
     const props =
@@ -1051,13 +980,6 @@ export default function Page() {
     }
   }
 
-  const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
-  const wantsReview = createMemo(() =>
-    isDesktop()
-      ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-      : store.mobileTab === "changes",
-  )
-
   createEffect(() => {
     const list = changesOptions()
     if (list.includes(store.changes)) return
@@ -1066,22 +988,12 @@ export default function Page() {
     setStore("changes", next)
   })
 
-  createEffect(() => {
-    const mode = vcsMode()
-    if (!mode) return
-    if (!wantsReview()) return
-    void loadVcs(mode)
-  })
-
   createEffect(
     on(
       () => sync.data.session_status[params.id ?? ""]?.type,
       (next, prev) => {
-        const mode = vcsMode()
-        if (!mode) return
-        if (!wantsReview()) return
         if (next !== "idle" || prev === undefined || prev === "idle") return
-        void loadVcs(mode, true)
+        refreshVcs()
       },
       { defer: true },
     ),

+ 97 - 84
packages/app/src/pages/session/session-side-panel.tsx

@@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
+import { usePlatform } from "@/context/platform"
+import { useSettings } from "@/context/settings"
+import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
   size: Sizing
 }) {
   const layout = useLayout()
+  const platform = usePlatform()
+  const settings = useSettings()
+  const sync = useSync()
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
@@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
   const { sessionKey, tabs, view } = useSessionLayout()
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
+  const shown = createMemo(
+    () =>
+      platform.platform !== "desktop" ||
+      import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
+      settings.general.showFileTree(),
+  )
 
   const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
-  const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
+  const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
   const open = createMemo(() => reviewOpen() || fileOpen())
   const reviewTab = createMemo(() => isDesktop())
   const panelWidth = createMemo(() => {
@@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
             </div>
           </div>
 
-          <div
-            id="file-tree-panel"
-            aria-hidden={!fileOpen()}
-            inert={!fileOpen()}
-            class="relative min-w-0 h-full shrink-0 overflow-hidden"
-            classList={{
-              "pointer-events-none": !fileOpen(),
-              "transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
-                !props.size.active(),
-            }}
-            style={{ width: treeWidth() }}
-          >
+          <Show when={shown()}>
             <div
-              class="h-full flex flex-col overflow-hidden group/filetree"
-              classList={{ "border-l border-border-weaker-base": reviewOpen() }}
+              id="file-tree-panel"
+              aria-hidden={!fileOpen()}
+              inert={!fileOpen()}
+              class="relative min-w-0 h-full shrink-0 overflow-hidden"
+              classList={{
+                "pointer-events-none": !fileOpen(),
+                "transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+                  !props.size.active(),
+              }}
+              style={{ width: treeWidth() }}
             >
-              <Tabs
-                variant="pill"
-                value={fileTreeTab()}
-                onChange={setFileTreeTabValue}
-                class="h-full"
-                data-scope="filetree"
+              <div
+                class="h-full flex flex-col overflow-hidden group/filetree"
+                classList={{ "border-l border-border-weaker-base": reviewOpen() }}
               >
-                <Tabs.List>
-                  <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
-                    {props.reviewCount()}{" "}
-                    {language.t(
-                      props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
-                    )}
-                  </Tabs.Trigger>
-                  <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
-                    {language.t("session.files.all")}
-                  </Tabs.Trigger>
-                </Tabs.List>
-                <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
-                  <Switch>
-                    <Match when={props.hasReview() || !props.diffsReady()}>
-                      <Show
-                        when={props.diffsReady()}
-                        fallback={
-                          <div class="px-2 py-2 text-12-regular text-text-weak">
-                            {language.t("common.loading")}
-                            {language.t("common.loading.ellipsis")}
-                          </div>
-                        }
-                      >
+                <Tabs
+                  variant="pill"
+                  value={fileTreeTab()}
+                  onChange={setFileTreeTabValue}
+                  class="h-full"
+                  data-scope="filetree"
+                >
+                  <Tabs.List>
+                    <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
+                      {props.reviewCount()}{" "}
+                      {language.t(
+                        props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
+                      )}
+                    </Tabs.Trigger>
+                    <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
+                      {language.t("session.files.all")}
+                    </Tabs.Trigger>
+                  </Tabs.List>
+                  <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
+                    <Switch>
+                      <Match when={props.hasReview() || !props.diffsReady()}>
+                        <Show
+                          when={props.diffsReady()}
+                          fallback={
+                            <div class="px-2 py-2 text-12-regular text-text-weak">
+                              {language.t("common.loading")}
+                              {language.t("common.loading.ellipsis")}
+                            </div>
+                          }
+                        >
+                          <FileTree
+                            path=""
+                            class="pt-3"
+                            allowed={diffFiles()}
+                            kinds={kinds()}
+                            draggable={false}
+                            active={props.activeDiff}
+                            onFileClick={(node) => props.focusReviewDiff(node.path)}
+                          />
+                        </Show>
+                      </Match>
+                    </Switch>
+                  </Tabs.Content>
+                  <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
+                    <Switch>
+                      <Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
+                      <Match when={true}>
                         <FileTree
                           path=""
                           class="pt-3"
-                          allowed={diffFiles()}
+                          modified={diffFiles()}
                           kinds={kinds()}
-                          draggable={false}
-                          active={props.activeDiff}
-                          onFileClick={(node) => props.focusReviewDiff(node.path)}
+                          onFileClick={(node) => openTab(file.tab(node.path))}
                         />
-                      </Show>
-                    </Match>
-                    <Match when={true}>{empty(props.empty())}</Match>
-                  </Switch>
-                </Tabs.Content>
-                <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
-                  <Switch>
-                    <Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
-                    <Match when={true}>
-                      <FileTree
-                        path=""
-                        class="pt-3"
-                        modified={diffFiles()}
-                        kinds={kinds()}
-                        onFileClick={(node) => openTab(file.tab(node.path))}
-                      />
-                    </Match>
-                  </Switch>
-                </Tabs.Content>
-              </Tabs>
-            </div>
-            <Show when={fileOpen()}>
-              <div onPointerDown={() => props.size.start()}>
-                <ResizeHandle
-                  direction="horizontal"
-                  edge="start"
-                  size={layout.fileTree.width()}
-                  min={200}
-                  max={480}
-                  onResize={(width) => {
-                    props.size.touch()
-                    layout.fileTree.resize(width)
-                  }}
-                />
+                      </Match>
+                    </Switch>
+                  </Tabs.Content>
+                </Tabs>
               </div>
-            </Show>
-          </div>
+              <Show when={fileOpen()}>
+                <div onPointerDown={() => props.size.start()}>
+                  <ResizeHandle
+                    direction="horizontal"
+                    edge="start"
+                    size={layout.fileTree.width()}
+                    min={200}
+                    max={480}
+                    onResize={(width) => {
+                      props.size.touch()
+                      layout.fileTree.resize(width)
+                    }}
+                  />
+                </div>
+              </Show>
+            </div>
+          </Show>
         </div>
       </aside>
     </Show>

+ 18 - 6
packages/app/src/pages/session/use-session-commands.tsx

@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
 import { usePermission } from "@/context/permission"
+import { usePlatform } from "@/context/platform"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
+import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { showToast } from "@opencode-ai/ui/toast"
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const language = useLanguage()
   const local = useLocal()
   const permission = usePermission()
+  const platform = usePlatform()
   const prompt = usePrompt()
   const sdk = useSDK()
+  const settings = useSettings()
   const sync = useSync()
   const terminal = useTerminal()
   const layout = useLayout()
@@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   })
   const activeFileTab = tabState.activeFileTab
   const closableTab = tabState.closableTab
+  const shown = () =>
+    platform.platform !== "desktop" ||
+    import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
+    settings.general.showFileTree()
 
   const idle = { type: "idle" as const }
   const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
       keybind: "mod+shift+r",
       onSelect: () => view().reviewPanel.toggle(),
     }),
-    viewCommand({
-      id: "fileTree.toggle",
-      title: language.t("command.fileTree.toggle"),
-      keybind: "mod+\\",
-      onSelect: () => layout.fileTree.toggle(),
-    }),
+    ...(shown()
+      ? [
+          viewCommand({
+            id: "fileTree.toggle",
+            title: language.t("command.fileTree.toggle"),
+            keybind: "mod+\\",
+            onSelect: () => layout.fileTree.toggle(),
+          }),
+        ]
+      : []),
     viewCommand({
       id: "input.focus",
       title: language.t("command.input.focus"),

+ 1 - 1
packages/app/src/utils/persist.ts

@@ -469,7 +469,7 @@ export function persisted<T>(
     state,
     setState,
     init,
-    Object.assign(() => ready() === true, {
+    Object.assign(() => (ready.loading ? false : ready.latest === true), {
       promise: init instanceof Promise ? init : undefined,
     }),
   ]

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

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

+ 22 - 2
packages/console/app/src/routes/zen/util/handler.ts

@@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
 import { Resource } from "@opencode-ai/console-resource"
 import { i18n, type Key } from "~/i18n"
 import { localeFromRequest } from "~/lib/language"
+import { createModelTpmLimiter } from "./modelTpmLimiter"
 
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type RetryOptions = {
@@ -121,6 +122,8 @@ export async function handler(
     const authInfo = await authenticate(modelInfo, zenApiKey)
     const billingSource = validateBilling(authInfo, modelInfo)
     logger.metric({ source: billingSource })
+    const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
+    const modelTpmLimits = await modelTpmLimiter?.check()
 
     const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
       const providerInfo = selectProvider(
@@ -133,6 +136,7 @@ export async function handler(
         trialProviders,
         retry,
         stickyProvider,
+        modelTpmLimits,
       )
       validateModelSettings(billingSource, authInfo)
       updateProviderKey(authInfo, providerInfo)
@@ -229,6 +233,7 @@ export async function handler(
         const usageInfo = providerInfo.normalizeUsage(json.usage)
         const costInfo = calculateCost(modelInfo, usageInfo)
         await trialLimiter?.track(usageInfo)
+        await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
         await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
         await reload(billingSource, authInfo, costInfo)
         json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -278,6 +283,7 @@ export async function handler(
                   const usageInfo = providerInfo.normalizeUsage(usage)
                   const costInfo = calculateCost(modelInfo, usageInfo)
                   await trialLimiter?.track(usageInfo)
+                  await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
                   await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
                   await reload(billingSource, authInfo, costInfo)
                   const cost = calculateOccurredCost(billingSource, costInfo)
@@ -433,12 +439,16 @@ export async function handler(
     trialProviders: string[] | undefined,
     retry: RetryOptions,
     stickyProvider: string | undefined,
+    modelTpmLimits: Record<string, number> | undefined,
   ) {
     const modelProvider = (() => {
+      // Byok is top priority b/c if user set their own API key, we should use it
+      // instead of using the sticky provider for the same session
       if (authInfo?.provider?.credentials) {
         return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
       }
 
+      // Always use the same provider for the same session
       if (stickyProvider) {
         const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
         if (provider) return provider
@@ -451,10 +461,20 @@ export async function handler(
       }
 
       if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
-        const providers = modelInfo.providers
+        const allProviders = modelInfo.providers
           .filter((provider) => !provider.disabled)
+          .filter((provider) => provider.weight !== 0)
           .filter((provider) => !retry.excludeProviders.includes(provider.id))
-          .flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
+          .filter((provider) => {
+            if (!provider.tpmLimit) return true
+            const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
+            return usage < provider.tpmLimit * 1_000_000
+          })
+
+        const topPriority = Math.min(...allProviders.map((p) => p.priority))
+        const providers = allProviders
+          .filter((p) => p.priority <= topPriority)
+          .flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
 
         // Use the last 4 characters of session ID to select a provider
         const identifier = sessionId.length ? sessionId : ip

+ 51 - 0
packages/console/app/src/routes/zen/util/modelTpmLimiter.ts

@@ -0,0 +1,51 @@
+import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
+import { UsageInfo } from "./provider/provider"
+
+export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
+  const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
+  if (keys.length === 0) return
+
+  const yyyyMMddHHmm = new Date(Date.now())
+    .toISOString()
+    .replace(/[^0-9]/g, "")
+    .substring(0, 12)
+
+  return {
+    check: async () => {
+      const data = await Database.use((tx) =>
+        tx
+          .select()
+          .from(ModelRateLimitTable)
+          .where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
+      )
+
+      // convert to map of model to count
+      return data.reduce(
+        (acc, curr) => {
+          acc[curr.key] = curr.count
+          return acc
+        },
+        {} as Record<string, number>,
+      )
+    },
+    track: async (id: string, model: string, usageInfo: UsageInfo) => {
+      const key = `${id}/${model}`
+      if (!keys.includes(key)) return
+      const usage =
+        usageInfo.inputTokens +
+        usageInfo.outputTokens +
+        (usageInfo.reasoningTokens ?? 0) +
+        (usageInfo.cacheReadTokens ?? 0) +
+        (usageInfo.cacheWrite5mTokens ?? 0) +
+        (usageInfo.cacheWrite1hTokens ?? 0)
+      if (usage <= 0) return
+      await Database.use((tx) =>
+        tx
+          .insert(ModelRateLimitTable)
+          .values({ key, interval: yyyyMMddHHmm, count: usage })
+          .onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
+      )
+    },
+  }
+}

+ 6 - 0
packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql

@@ -0,0 +1,6 @@
+CREATE TABLE `model_rate_limit` (
+	`key` varchar(255) NOT NULL,
+	`interval` varchar(40) NOT NULL,
+	`count` int NOT NULL,
+	CONSTRAINT PRIMARY KEY(`key`,`interval`)
+);

+ 2567 - 0
packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json

@@ -0,0 +1,2567 @@
+{
+  "version": "6",
+  "dialect": "mysql",
+  "id": "93c492af-c95b-4213-9fc2-38c3dd10374d",
+  "prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"],
+  "ddl": [
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "auth",
+      "entityType": "tables"
+    },
+    {
+      "name": "benchmark",
+      "entityType": "tables"
+    },
+    {
+      "name": "billing",
+      "entityType": "tables"
+    },
+    {
+      "name": "lite",
+      "entityType": "tables"
+    },
+    {
+      "name": "payment",
+      "entityType": "tables"
+    },
+    {
+      "name": "subscription",
+      "entityType": "tables"
+    },
+    {
+      "name": "usage",
+      "entityType": "tables"
+    },
+    {
+      "name": "ip_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "ip",
+      "entityType": "tables"
+    },
+    {
+      "name": "key_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "model_rate_limit",
+      "entityType": "tables"
+    },
+    {
+      "name": "key",
+      "entityType": "tables"
+    },
+    {
+      "name": "model",
+      "entityType": "tables"
+    },
+    {
+      "name": "provider",
+      "entityType": "tables"
+    },
+    {
+      "name": "user",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "enum('email','github','google')",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subject",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "account_id",
+      "entityType": "columns",
+      "table": "auth"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "mediumtext",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "result",
+      "entityType": "columns",
+      "table": "benchmark"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "customer_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(32)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_type",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(4)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_method_last4",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "balance",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_limit",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_usage_updated",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "boolean",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_trigger",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_amount",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reload_error",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_reload_error",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_reload_locked_till",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(28)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "enum('20','100','200')",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "subscription_plan",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_subscription_booked",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_subscription_selected",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(28)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "lite_subscription_id",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "lite",
+      "entityType": "columns",
+      "table": "billing"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "rolling_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "weekly_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_rolling_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_weekly_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_updated",
+      "entityType": "columns",
+      "table": "lite"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "customer_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "invoice_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "payment_id",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "amount",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_refunded",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "enrichment",
+      "entityType": "columns",
+      "table": "payment"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "rolling_usage",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "fixed_usage",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_rolling_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_fixed_updated",
+      "entityType": "columns",
+      "table": "subscription"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "input_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "output_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "reasoning_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_read_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_write_5m_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cache_write_1h_tokens",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "bigint",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "cost",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "json",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "enrichment",
+      "entityType": "columns",
+      "table": "usage"
+    },
+    {
+      "type": "varchar(45)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "ip",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "varchar(10)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "ip_rate_limit"
+    },
+    {
+      "type": "varchar(45)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "ip",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "usage",
+      "entityType": "columns",
+      "table": "ip"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "varchar(40)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "key_rate_limit"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key",
+      "entityType": "columns",
+      "table": "model_rate_limit"
+    },
+    {
+      "type": "varchar(40)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "interval",
+      "entityType": "columns",
+      "table": "model_rate_limit"
+    },
+    {
+      "type": "int",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "count",
+      "entityType": "columns",
+      "table": "model_rate_limit"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "key",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "user_id",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "key"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "model"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(64)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "provider",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "credentials",
+      "entityType": "columns",
+      "table": "provider"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "account_id",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_seen",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "color",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "enum('admin','member')",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "role",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "int",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_limit",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "bigint",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "monthly_usage",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_monthly_usage_updated",
+      "entityType": "columns",
+      "table": "user"
+    },
+    {
+      "type": "varchar(30)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "varchar(255)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(now())",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": true,
+      "autoIncrement": false,
+      "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "timestamp(3)",
+      "notNull": false,
+      "autoIncrement": false,
+      "default": null,
+      "onUpdateNow": false,
+      "onUpdateNowFsp": null,
+      "charSet": null,
+      "collation": null,
+      "generated": null,
+      "name": "time_deleted",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "columns": ["id"],
+      "name": "PRIMARY",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "name": "PRIMARY",
+      "table": "auth",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "name": "PRIMARY",
+      "table": "benchmark",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "billing",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "lite",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "payment",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "subscription",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "usage",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["ip", "interval"],
+      "name": "PRIMARY",
+      "table": "ip_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["ip"],
+      "name": "PRIMARY",
+      "table": "ip",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["key", "interval"],
+      "name": "PRIMARY",
+      "table": "key_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["key", "interval"],
+      "name": "PRIMARY",
+      "table": "model_rate_limit",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "key",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "model",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "provider",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["workspace_id", "id"],
+      "name": "PRIMARY",
+      "table": "user",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "name": "PRIMARY",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "provider",
+          "isExpression": false
+        },
+        {
+          "value": "subject",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "provider",
+      "entityType": "indexes",
+      "table": "auth"
+    },
+    {
+      "columns": [
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "account_id",
+      "entityType": "indexes",
+      "table": "auth"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "time_created",
+      "entityType": "indexes",
+      "table": "benchmark"
+    },
+    {
+      "columns": [
+        {
+          "value": "customer_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_customer_id",
+      "entityType": "indexes",
+      "table": "billing"
+    },
+    {
+      "columns": [
+        {
+          "value": "subscription_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_subscription_id",
+      "entityType": "indexes",
+      "table": "billing"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "user_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_user_id",
+      "entityType": "indexes",
+      "table": "lite"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "user_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_user_id",
+      "entityType": "indexes",
+      "table": "subscription"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "usage_time_created",
+      "entityType": "indexes",
+      "table": "usage"
+    },
+    {
+      "columns": [
+        {
+          "value": "key",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_key",
+      "entityType": "indexes",
+      "table": "key"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "model",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "model_workspace_model",
+      "entityType": "indexes",
+      "table": "model"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "provider",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "workspace_provider",
+      "entityType": "indexes",
+      "table": "provider"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "user_account_id",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        },
+        {
+          "value": "email",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "user_email",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "account_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_account_id",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "email",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "global_email",
+      "entityType": "indexes",
+      "table": "user"
+    },
+    {
+      "columns": [
+        {
+          "value": "slug",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "using": null,
+      "algorithm": null,
+      "lock": null,
+      "nameExplicit": true,
+      "name": "slug",
+      "entityType": "indexes",
+      "table": "workspace"
+    }
+  ],
+  "renames": []
+}

+ 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.6",
+  "version": "1.4.11",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 11 - 4
packages/console/core/src/model.ts

@@ -34,6 +34,8 @@ export namespace ZenData {
       z.object({
         id: z.string(),
         model: z.string(),
+        priority: z.number().optional(),
+        tpmLimit: z.number().optional(),
         weight: z.number().optional(),
         disabled: z.boolean().optional(),
         storeModel: z.string().optional(),
@@ -123,10 +125,16 @@ export namespace ZenData {
       ),
       models: (() => {
         const normalize = (model: z.infer<typeof ModelSchema>) => {
-          const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
+          const providers = model.providers.map((p) => ({
+            ...p,
+            priority: p.priority ?? Infinity,
+            weight: p.weight ?? 1,
+          }))
+          const composite = providers.find((p) => compositeProviders[p.id].length > 1)
           if (!composite)
             return {
               trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
+              providers,
             }
 
           const weightMulti = compositeProviders[composite.id].length
@@ -137,17 +145,16 @@ export namespace ZenData {
               if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
               return [model.trialProvider]
             })(),
-            providers: model.providers.flatMap((p) =>
+            providers: providers.flatMap((p) =>
               p.id === composite.id
                 ? compositeProviders[p.id].map((sub) => ({
                     ...p,
                     id: sub.id,
-                    weight: p.weight ?? 1,
                   }))
                 : [
                     {
                       ...p,
-                      weight: (p.weight ?? 1) * weightMulti,
+                      weight: p.weight * weightMulti,
                     },
                   ],
             ),

+ 10 - 0
packages/console/core/src/schema/ip.sql.ts

@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
   },
   (table) => [primaryKey({ columns: [table.key, table.interval] })],
 )
+
+export const ModelRateLimitTable = mysqlTable(
+  "model_rate_limit",
+  {
+    key: varchar("key", { length: 255 }).notNull(),
+    interval: varchar("interval", { length: 40 }).notNull(),
+    count: int("count").notNull(),
+  },
+  (table) => [primaryKey({ columns: [table.key, table.interval] })],
+)

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

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

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

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

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

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

+ 1 - 1
packages/desktop/package.json

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

+ 1 - 1
packages/enterprise/package.json

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

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

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

+ 1 - 1
packages/function/package.json

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

+ 0 - 31
packages/opencode/.opencode/package-lock.json

@@ -1,31 +0,0 @@
-{
-  "name": ".opencode",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "dependencies": {
-        "@opencode-ai/plugin": "*"
-      }
-    },
-    "node_modules/@opencode-ai/plugin": {
-      "version": "1.2.6",
-      "license": "MIT",
-      "dependencies": {
-        "@opencode-ai/sdk": "1.2.6",
-        "zod": "4.1.8"
-      }
-    },
-    "node_modules/@opencode-ai/sdk": {
-      "version": "1.2.6",
-      "license": "MIT"
-    },
-    "node_modules/zod": {
-      "version": "4.1.8",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/colinhacks"
-      }
-    }
-  }
-}

+ 67 - 0
packages/opencode/AGENTS.md

@@ -9,6 +9,63 @@
 - **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
 - **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
 
+# Module shape
+
+Do not use `export namespace Foo { ... }` for module organization. It is not
+standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
+runner. Use flat top-level exports combined with a self-reexport at the bottom
+of the file:
+
+```ts
+// src/foo/foo.ts
+export interface Interface { ... }
+export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
+export const layer = Layer.effect(Service, ...)
+export const defaultLayer = layer.pipe(...)
+
+export * as Foo from "./foo"
+```
+
+Consumers import the namespace projection:
+
+```ts
+import { Foo } from "@/foo/foo"
+
+yield * Foo.Service
+Foo.layer
+Foo.defaultLayer
+```
+
+Namespace-private helpers stay as non-exported top-level declarations in the
+same file — they remain inaccessible to consumers (they are not projected by
+`export * as`) but are usable by the file's own code.
+
+## When the file is an `index.ts`
+
+If the module is `foo/index.ts` (single-namespace directory), use `"."` for
+the self-reexport source rather than `"./index"`:
+
+```ts
+// src/foo/index.ts
+export const thing = ...
+
+export * as Foo from "."
+```
+
+## Multi-sibling directories
+
+For directories with several independent modules (e.g. `src/session/`,
+`src/config/`), keep each sibling as its own file with its own self-reexport,
+and do not add a barrel `index.ts`. Consumers import the specific sibling:
+
+```ts
+import { SessionRetry } from "@/session/retry"
+import { SessionStatus } from "@/session/status"
+```
+
+Barrels in multi-sibling directories force every import through the barrel to
+evaluate every sibling, which defeats tree-shaking and slows module load.
+
 # opencode Effect rules
 
 Use these rules when writing or migrating Effect code.
@@ -23,6 +80,10 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
 - Use `Effect.callback` for callback-based APIs.
 - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
 
+## Module conventions
+
+- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
+
 ## Schemas and errors
 
 - Use `Schema.Class` for multi-field data.
@@ -39,6 +100,12 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
 - Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
 - Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
 - Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
+- To make a service's `init()` non-blocking, fork `InstanceState.get(state)` at the `init()` call site (e.g. `Effect.forkIn(scope)`), not by forking work inside the `InstanceState.make` closure. Forking inside the closure leaves state incomplete for other methods that read it.
+- `src/project/bootstrap.ts` already wraps every service `init()` in `Effect.forkDetach`, so `init()` is fire-and-forget in production. Keep `init()` methods synchronous internally; the caller controls concurrency.
+
+## Effect v4 beta API
+
+- `Effect.fork` and `Effect.forkDaemon` do not exist. Use `Effect.forkIn(scope)` to fork a fiber into a specific scope.
 
 ## Preferred Effect services
 

+ 8 - 8
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.4.6",
+  "version": "1.4.11",
   "name": "opencode",
   "type": "module",
   "license": "MIT",
@@ -79,15 +79,15 @@
     "@actions/github": "6.0.1",
     "@agentclientprotocol/sdk": "0.16.1",
     "@ai-sdk/alibaba": "1.0.17",
-    "@ai-sdk/amazon-bedrock": "4.0.93",
-    "@ai-sdk/anthropic": "3.0.67",
+    "@ai-sdk/amazon-bedrock": "4.0.95",
+    "@ai-sdk/anthropic": "3.0.71",
     "@ai-sdk/azure": "3.0.49",
     "@ai-sdk/cerebras": "2.0.41",
     "@ai-sdk/cohere": "3.0.27",
     "@ai-sdk/deepinfra": "2.0.41",
-    "@ai-sdk/gateway": "3.0.97",
+    "@ai-sdk/gateway": "3.0.104",
     "@ai-sdk/google": "3.0.63",
-    "@ai-sdk/google-vertex": "4.0.109",
+    "@ai-sdk/google-vertex": "4.0.112",
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/mistral": "3.0.27",
     "@ai-sdk/openai": "3.0.53",
@@ -122,8 +122,8 @@
     "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
     "@opentelemetry/sdk-trace-base": "2.6.1",
     "@opentelemetry/sdk-trace-node": "2.6.1",
-    "@opentui/core": "0.1.99",
-    "@opentui/solid": "0.1.99",
+    "@opentui/core": "catalog:",
+    "@opentui/solid": "catalog:",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",
@@ -143,7 +143,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "6.4.2",
+    "gitlab-ai-provider": "6.6.0",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",

+ 21 - 8
packages/opencode/script/publish.ts

@@ -7,6 +7,20 @@ import { fileURLToPath } from "url"
 const dir = fileURLToPath(new URL("..", import.meta.url))
 process.chdir(dir)
 
+async function published(name: string, version: string) {
+  return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
+}
+
+async function publish(dir: string, name: string, version: string) {
+  if (await published(name, version)) {
+    console.log(`already published ${name}@${version}`)
+    return
+  }
+  if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
+  await $`bun pm pack`.cwd(dir)
+  await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
+}
+
 const binaries: Record<string, string> = {}
 for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
   const pkg = await Bun.file(`./dist/${filepath}`).json()
@@ -40,14 +54,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
 )
 
 const tasks = Object.entries(binaries).map(async ([name]) => {
-  if (process.platform !== "win32") {
-    await $`chmod -R 755 .`.cwd(`./dist/${name}`)
-  }
-  await $`bun pm pack`.cwd(`./dist/${name}`)
-  await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`)
+  await publish(`./dist/${name}`, name, binaries[name])
 })
 await Promise.all(tasks)
-await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}`
+await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
 
 const image = "ghcr.io/anomalyco/opencode"
 const platforms = "linux/amd64,linux/arm64"
@@ -104,6 +114,7 @@ if (!Script.preview) {
         await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
         await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
         await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
+        if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
         await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
         await $`cd ./dist/aur-${pkg} && git push`
         break
@@ -176,6 +187,8 @@ if (!Script.preview) {
   await $`git clone ${tap} ./dist/homebrew-tap`
   await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
   await $`cd ./dist/homebrew-tap && git add opencode.rb`
-  await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
-  await $`cd ./dist/homebrew-tap && git push`
+  if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
+    await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
+    await $`cd ./dist/homebrew-tap && git push`
+  }
 }

+ 2 - 0
packages/opencode/time.ts → packages/opencode/script/time.ts

@@ -1,3 +1,5 @@
+#!/usr/bin/env bun
+
 import path from "path"
 const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
 await import(toDynamicallyImport)

+ 0 - 0
packages/opencode/trace-imports.ts → packages/opencode/script/trace-imports.ts


+ 0 - 305
packages/opencode/script/unwrap-namespace.ts

@@ -1,305 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Unwrap a TypeScript `export namespace` into flat exports + barrel.
- *
- * Usage:
- *   bun script/unwrap-namespace.ts src/bus/index.ts
- *   bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
- *   bun script/unwrap-namespace.ts src/pty/index.ts --name service   # avoid collision with pty.ts
- *
- * What it does:
- *   1. Reads the file and finds the `export namespace Foo { ... }` block
- *      (uses ast-grep for accurate AST-based boundary detection)
- *   2. Removes the namespace wrapper and dedents the body
- *   3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
- *   4. If the file is index.ts, renames it to <lowercase-name>.ts
- *   5. Creates/updates index.ts with `export * as Foo from "./<file>"`
- *   6. Rewrites import paths across src/, test/, and script/
- *   7. Fixes sibling imports within the same directory
- *
- * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
- */
-
-import path from "path"
-import fs from "fs"
-
-const args = process.argv.slice(2)
-const dryRun = args.includes("--dry-run")
-const nameFlag = args.find((a, i) => args[i - 1] === "--name")
-const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
-
-if (!filePath) {
-  console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
-  process.exit(1)
-}
-
-const absPath = path.resolve(filePath)
-if (!fs.existsSync(absPath)) {
-  console.error(`File not found: ${absPath}`)
-  process.exit(1)
-}
-
-const src = fs.readFileSync(absPath, "utf-8")
-const lines = src.split("\n")
-
-// Use ast-grep to find the namespace boundaries accurately.
-// This avoids false matches from braces in strings, templates, comments, etc.
-const astResult = Bun.spawnSync(
-  ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
-  { stdout: "pipe", stderr: "pipe" },
-)
-
-if (astResult.exitCode !== 0) {
-  console.error("ast-grep failed:", astResult.stderr.toString())
-  process.exit(1)
-}
-
-const matches = JSON.parse(astResult.stdout.toString()) as Array<{
-  text: string
-  range: { start: { line: number; column: number }; end: { line: number; column: number } }
-  metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
-}>
-
-if (matches.length === 0) {
-  console.error("No `export namespace Foo { ... }` found in file")
-  process.exit(1)
-}
-
-if (matches.length > 1) {
-  console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
-  console.error("Namespaces found:")
-  for (const m of matches) console.error(`  ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
-  process.exit(1)
-}
-
-const match = matches[0]
-const nsName = match.metaVariables.single.NAME.text
-const nsLine = match.range.start.line // 0-indexed
-const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
-
-console.log(`Found: export namespace ${nsName} { ... }`)
-console.log(`  Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
-
-// Build the new file content:
-// 1. Everything before the namespace declaration (imports, etc.)
-// 2. The namespace body, dedented by one level (2 spaces)
-// 3. Everything after the closing brace (rare, but possible)
-const before = lines.slice(0, nsLine)
-const body = lines.slice(nsLine + 1, closeLine)
-const after = lines.slice(closeLine + 1)
-
-// Dedent: remove exactly 2 leading spaces from each line
-const dedented = body.map((line) => {
-  if (line === "") return ""
-  if (line.startsWith("  ")) return line.slice(2)
-  return line
-})
-
-let newContent = [...before, ...dedented, ...after].join("\n")
-
-// --- Fix self-references ---
-// After unwrapping, references like `Config.PermissionAction` inside the same file
-// need to become just `PermissionAction`. Only fix code positions, not strings.
-const exportedNames = new Set<string>()
-const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
-for (const line of dedented) {
-  for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
-}
-const reExportRegex = /export\s*\{\s*([^}]+)\}/g
-for (const line of dedented) {
-  for (const m of line.matchAll(reExportRegex)) {
-    for (const name of m[1].split(",")) {
-      const trimmed = name
-        .trim()
-        .split(/\s+as\s+/)
-        .pop()!
-        .trim()
-      if (trimmed) exportedNames.add(trimmed)
-    }
-  }
-}
-
-let selfRefCount = 0
-if (exportedNames.size > 0) {
-  const fixedLines = newContent.split("\n").map((line) => {
-    // Split line into string-literal and code segments to avoid replacing inside strings
-    const segments: Array<{ text: string; isString: boolean }> = []
-    let i = 0
-    let current = ""
-    let inString: string | null = null
-
-    while (i < line.length) {
-      const ch = line[i]
-      if (inString) {
-        current += ch
-        if (ch === "\\" && i + 1 < line.length) {
-          current += line[i + 1]
-          i += 2
-          continue
-        }
-        if (ch === inString) {
-          segments.push({ text: current, isString: true })
-          current = ""
-          inString = null
-        }
-        i++
-        continue
-      }
-      if (ch === '"' || ch === "'" || ch === "`") {
-        if (current) segments.push({ text: current, isString: false })
-        current = ch
-        inString = ch
-        i++
-        continue
-      }
-      if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
-        current += line.slice(i)
-        segments.push({ text: current, isString: true })
-        current = ""
-        i = line.length
-        continue
-      }
-      current += ch
-      i++
-    }
-    if (current) segments.push({ text: current, isString: !!inString })
-
-    return segments
-      .map((seg) => {
-        if (seg.isString) return seg.text
-        let result = seg.text
-        for (const name of exportedNames) {
-          const pattern = `${nsName}.${name}`
-          while (result.includes(pattern)) {
-            const idx = result.indexOf(pattern)
-            const charBefore = idx > 0 ? result[idx - 1] : " "
-            const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
-            if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
-            result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
-            selfRefCount++
-          }
-        }
-        return result
-      })
-      .join("")
-  })
-  newContent = fixedLines.join("\n")
-}
-
-// Figure out file naming
-const dir = path.dirname(absPath)
-const basename = path.basename(absPath, ".ts")
-const isIndex = basename === "index"
-const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
-const implFile = path.join(dir, `${implName}.ts`)
-const indexFile = path.join(dir, "index.ts")
-const barrelLine = `export * as ${nsName} from "./${implName}"\n`
-
-console.log("")
-if (isIndex) {
-  console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
-} else {
-  console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
-}
-if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
-console.log("")
-
-if (dryRun) {
-  console.log("--- DRY RUN ---")
-  console.log("")
-  console.log(`=== ${implName}.ts (first 30 lines) ===`)
-  newContent
-    .split("\n")
-    .slice(0, 30)
-    .forEach((l, i) => console.log(`  ${i + 1}: ${l}`))
-  console.log("  ...")
-  console.log("")
-  console.log(`=== index.ts ===`)
-  console.log(`  ${barrelLine.trim()}`)
-  console.log("")
-  if (!isIndex) {
-    const relDir = path.relative(path.resolve("src"), dir)
-    console.log(`=== Import rewrites (would apply) ===`)
-    console.log(`  ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
-  } else {
-    console.log("No import rewrites needed (was index.ts)")
-  }
-} else {
-  if (isIndex) {
-    fs.writeFileSync(implFile, newContent)
-    fs.writeFileSync(indexFile, barrelLine)
-    console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
-    console.log(`Wrote index.ts (barrel)`)
-  } else {
-    fs.writeFileSync(absPath, newContent)
-    if (fs.existsSync(indexFile)) {
-      const existing = fs.readFileSync(indexFile, "utf-8")
-      if (!existing.includes(`export * as ${nsName}`)) {
-        fs.appendFileSync(indexFile, barrelLine)
-        console.log(`Appended to existing index.ts`)
-      } else {
-        console.log(`index.ts already has ${nsName} export`)
-      }
-    } else {
-      fs.writeFileSync(indexFile, barrelLine)
-      console.log(`Wrote index.ts (barrel)`)
-    }
-    console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
-  }
-
-  // --- Rewrite import paths across src/, test/, script/ ---
-  const relDir = path.relative(path.resolve("src"), dir)
-  if (!isIndex) {
-    const oldTail = `${relDir}/${basename}`
-    const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
-    const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    const filesToRewrite = rgResult.stdout
-      .toString()
-      .trim()
-      .split("\n")
-      .filter((f) => f.length > 0)
-
-    if (filesToRewrite.length > 0) {
-      console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
-      for (const file of filesToRewrite) {
-        const content = fs.readFileSync(file, "utf-8")
-        fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
-      }
-      console.log(`  Done: ${oldTail}" → ${relDir}"`)
-    } else {
-      console.log("\nNo import rewrites needed")
-    }
-  } else {
-    console.log("\nNo import rewrites needed (was index.ts)")
-  }
-
-  // --- Fix sibling imports within the same directory ---
-  const siblingFiles = fs.readdirSync(dir).filter((f) => {
-    if (!f.endsWith(".ts")) return false
-    if (f === "index.ts" || f === `${implName}.ts`) return false
-    return true
-  })
-
-  let siblingFixCount = 0
-  for (const sibFile of siblingFiles) {
-    const sibPath = path.join(dir, sibFile)
-    const content = fs.readFileSync(sibPath, "utf-8")
-    const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
-    if (pattern.test(content)) {
-      fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
-      siblingFixCount++
-    }
-  }
-  if (siblingFixCount > 0) {
-    console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
-  }
-}
-
-console.log("")
-console.log("=== Verify ===")
-console.log("")
-console.log("bunx --bun tsgo --noEmit   # typecheck")
-console.log("bun run test               # run tests")

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

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

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

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

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

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

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

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

+ 31 - 43
packages/opencode/specs/effect/migration.md

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

+ 0 - 499
packages/opencode/specs/effect/namespace-treeshake.md

@@ -1,499 +0,0 @@
-# Namespace → flat export migration
-
-Migrate `export namespace` to the `export * as` / flat-export pattern used by
-effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
-conventions, LLM-friendliness for future migrations.
-
-## What changes and what doesn't
-
-The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
-`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
-
-What changes is **how** the namespace is constructed — the TypeScript
-`export namespace` keyword is replaced by `export * as` in a barrel file. This
-is a mechanical change: unwrap the namespace body into flat exports, add a
-one-line barrel. Consumers that import `{ Provider }` don't notice.
-
-Import paths actually get **nicer**. Today most consumers import from the
-explicit file (`"../provider/provider"`). After the migration, each module has a
-barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
-
-```ts
-// BEFORE — points at the file directly
-import { Provider } from "../provider/provider"
-
-// AFTER — resolves to provider/index.ts, same Provider namespace
-import { Provider } from "../provider"
-```
-
-## Why this matters right now
-
-The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
-massive dependency graphs that are never actually used at runtime — because
-bundlers cannot tree-shake TypeScript `export namespace` bodies.
-
-### The problem in one sentence
-
-`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
-importing `{ Provider }` from `provider.ts` forces the bundler to include **all
-20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
-`google-auth-library`, and every other top-level import in that 1709-line file.
-
-### Why `export namespace` defeats tree-shaking
-
-TypeScript compiles `export namespace Foo { ... }` to an IIFE:
-
-```js
-// TypeScript output
-export var Provider;
-(function (Provider) {
-  Provider.ModelNotFoundError = NamedError.create(...)
-  // ... 1600 more lines of assignments ...
-})(Provider || (Provider = {}))
-```
-
-This is **opaque to static analysis**. The bundler sees one big function call
-whose return value populates an object. It cannot determine which properties are
-used downstream, so it keeps everything. Every `import` statement at the top of
-`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
-memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
-
-### What `export * as` does differently
-
-`export * as Provider from "./provider"` compiles to a static re-export. The
-bundler knows the exact shape of `Provider` at compile time — it's the named
-export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
-but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
-reference `createAnthropic` or any AI SDK import, and drop them. The namespace
-object still exists at runtime — same API — but the bundler can see inside it.
-
-### Concrete impact
-
-The worst import chain in the codebase:
-
-```
-src/index.ts (entry point)
-  └── FormatError from src/cli/error.ts
-        ├── { Provider } from provider/provider.ts     (1709 lines)
-        │     ├── 20+ @ai-sdk/* packages
-        │     ├── @aws-sdk/credential-providers
-        │     ├── google-auth-library
-        │     ├── gitlab-ai-provider, venice-ai-sdk-provider
-        │     └── fuzzysort, remeda, etc.
-        ├── { Config } from config/config.ts           (1663 lines)
-        │     ├── jsonc-parser
-        │     ├── LSPServer (all server definitions)
-        │     └── Plugin, Auth, Env, Account, etc.
-        └── { MCP } from mcp/index.ts                  (930 lines)
-              ├── @modelcontextprotocol/sdk (3 transports)
-              └── open (browser launcher)
-```
-
-All of this gets pulled in to check `.isInstance()` on 6 error classes — code
-that needs maybe 200 bytes total. This inflates the binary, increases startup
-memory, and slows down initial module evaluation.
-
-### Why this also hurts memory
-
-Every module-level import is eagerly evaluated. Even with Bun's fast module
-loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
-Google's auth library allocates objects, closures, and prototype chains that
-persist for the lifetime of the process. Most CLI commands never use a provider
-at all.
-
-## What effect-smol does
-
-effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
-
-### 1. Each module is a separate file with flat named exports
-
-```ts
-// Effect.ts — no namespace wrapper, just flat exports
-export const gen: { ... } = internal.gen
-export const fail: <E>(error: E) => Effect<never, E> = internal.fail
-export const succeed: <A>(value: A) => Effect<A> = internal.succeed
-// ... 230+ individual named exports
-```
-
-### 2. Barrel file uses `export * as` (not `export namespace`)
-
-```ts
-// index.ts
-export * as Effect from "./Effect.ts"
-export * as Schema from "./Schema.ts"
-export * as Stream from "./Stream.ts"
-// ~134 modules
-```
-
-This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
-bundler knows the **exact shape** at compile time — it's the static export list
-of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
-drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
-nothing can be dropped.
-
-### 3. `sideEffects: []` and deep imports
-
-```jsonc
-// package.json
-{ "sideEffects": [] }
-```
-
-Plus `"./*": "./src/*.ts"` in the exports map, enabling
-`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
-
-### 4. Errors as flat exports, not class declarations
-
-```ts
-// Cause.ts
-export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
-export interface NoSuchElementError extends YieldableError { ... }
-export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
-export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
-```
-
-Each error is 4 independent exports: TypeId, interface, constructor (as const),
-type guard. All individually shakeable.
-
-## The plan
-
-The core migration is **Phase 1** — convert `export namespace` to
-`export * as`. Once that's done, the bundler can tree-shake individual exports
-within each module. You do NOT need to break things into subfiles for
-tree-shaking to work — the bundler traces which exports you actually access on
-the namespace object and drops the rest, including their transitive imports.
-
-Splitting errors/schemas into separate files (Phase 0) is optional — it's a
-lower-risk warmup step that can be done before or after the main conversion, and
-it provides extra resilience against bundler edge cases. But the big win comes
-from Phase 1.
-
-### Phase 0 (optional): Pre-split errors into subfiles
-
-This is a low-risk warmup that provides immediate benefit even before the full
-`export * as` conversion. It's optional because Phase 1 alone is sufficient for
-tree-shaking. But it's a good starting point if you want incremental progress:
-
-**For each namespace that defines errors** (15 files, ~30 error classes total):
-
-1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
-   definitions as top-level named exports:
-
-   ```ts
-   // provider/errors.ts
-   import z from "zod"
-   import { NamedError } from "@opencode-ai/shared/util/error"
-   import { ProviderID, ModelID } from "./schema"
-
-   export const ModelNotFoundError = NamedError.create(
-     "ProviderModelNotFoundError",
-     z.object({
-       providerID: ProviderID.zod,
-       modelID: ModelID.zod,
-       suggestions: z.array(z.string()).optional(),
-     }),
-   )
-
-   export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
-   ```
-
-2. In the namespace file, re-export from the errors file to maintain backward
-   compatibility:
-
-   ```ts
-   // provider/provider.ts — inside the namespace
-   export { ModelNotFoundError, InitError } from "./errors"
-   ```
-
-3. Update `cli/error.ts` (and any other light consumers) to import directly:
-
-   ```ts
-   // BEFORE
-   import { Provider } from "../provider/provider"
-   Provider.ModelNotFoundError.isInstance(input)
-
-   // AFTER
-   import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
-   ProviderModelNotFoundError.isInstance(input)
-   ```
-
-**Files to split (Phase 0):**
-
-| Current file            | New errors file                 | Errors to extract                                                                                                       |
-| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
-| `provider/provider.ts`  | `provider/errors.ts`            | ModelNotFoundError, InitError                                                                                           |
-| `provider/auth.ts`      | `provider/auth-errors.ts`       | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed                                                   |
-| `config/config.ts`      | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts                                                                             |
-| `config/markdown.ts`    | `config/markdown-errors.ts`     | FrontmatterError                                                                                                        |
-| `mcp/index.ts`          | `mcp/errors.ts`                 | Failed                                                                                                                  |
-| `session/message-v2.ts` | `session/message-errors.ts`     | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError                       |
-| `session/message.ts`    | (shares with message-v2)        | OutputLengthError, AuthError                                                                                            |
-| `cli/ui.ts`             | `cli/ui-errors.ts`              | CancelledError                                                                                                          |
-| `skill/index.ts`        | `skill/errors.ts`               | InvalidError, NameMismatchError                                                                                         |
-| `worktree/index.ts`     | `worktree/errors.ts`            | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
-| `storage/storage.ts`    | `storage/errors.ts`             | NotFoundError                                                                                                           |
-| `npm/index.ts`          | `npm/errors.ts`                 | InstallFailedError                                                                                                      |
-| `ide/index.ts`          | `ide/errors.ts`                 | AlreadyInstalledError, InstallFailedError                                                                               |
-| `lsp/client.ts`         | `lsp/errors.ts`                 | InitializeError                                                                                                         |
-
-### Phase 1: The real migration — `export namespace` → `export * as`
-
-This is the phase that actually fixes tree-shaking. For each module:
-
-1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
-   keep all the members as top-level `export const` / `export function` / etc.
-2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
-   `bus/bus.ts`), so the barrel can take `index.ts`.
-3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
-
-The file structure change for a module that's currently a single file:
-
-```
-# BEFORE
-provider/
-  provider.ts        ← 1709-line file with `export namespace Provider { ... }`
-
-# AFTER
-provider/
-  index.ts           ← NEW: `export * as Provider from "./provider"`
-  provider.ts        ← SAME file, same name, just unwrap the namespace
-```
-
-And the code change is purely removing the wrapper:
-
-```ts
-// BEFORE: provider/provider.ts
-export namespace Provider {
-  export class Service extends Context.Service<...>()("@opencode/Provider") {}
-  export const layer = Layer.effect(Service, ...)
-  export const ModelNotFoundError = NamedError.create(...)
-  export function parseModel(model: string) { ... }
-}
-
-// AFTER: provider/provider.ts — identical exports, no namespace keyword
-export class Service extends Context.Service<...>()("@opencode/Provider") {}
-export const layer = Layer.effect(Service, ...)
-export const ModelNotFoundError = NamedError.create(...)
-export function parseModel(model: string) { ... }
-```
-
-```ts
-// NEW: provider/index.ts
-export * as Provider from "./provider"
-```
-
-Consumer code barely changes — import path gets shorter:
-
-```ts
-// BEFORE
-import { Provider } from "../provider/provider"
-
-// AFTER — resolves to provider/index.ts, same Provider object
-import { Provider } from "../provider"
-```
-
-All access like `Provider.ModelNotFoundError`, `Provider.Service`,
-`Provider.layer` works exactly as before. The difference is invisible to
-consumers but lets the bundler see inside the namespace.
-
-**Once this is done, you don't need to break anything into subfiles for
-tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
-depends on `NamedError` + `zod` + the schema file, and drops
-`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
-`export * as` gives the bundler a static export list it can do inner-graph
-analysis on — it knows which exports reference which imports.
-
-**Order of conversion** (by risk / size, do small modules first):
-
-1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
-2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
-3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
-4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
-
-### Phase 2: Build configuration
-
-After the module structure supports tree-shaking:
-
-1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
-   `"sideEffects": false`) — this is safe because our services use explicit
-   layer composition, not import-time side effects.
-2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
-   insufficient, evaluate whether the compiled binary path needs an esbuild
-   pre-pass.
-3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
-   — these are factory functions that return classes, and bundlers may not know
-   they're side-effect-free without the annotation.
-
-## Automation
-
-The transformation is scripted. From `packages/opencode`:
-
-```bash
-bun script/unwrap-namespace.ts <file> [--dry-run]
-```
-
-The script uses ast-grep for accurate AST-based namespace boundary detection
-(no false matches from braces in strings/templates/comments), then:
-
-1. Removes the `export namespace Foo {` line and its closing `}`
-2. Dedents the body by one indent level (2 spaces)
-3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
-   `index.ts` barrel
-4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
-5. Prints the exact commands to find and rewrite import paths
-
-### Walkthrough: converting a module
-
-Using `Provider` as an example:
-
-```bash
-# 1. Preview what will change
-bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
-
-# 2. Apply the transformation
-bun script/unwrap-namespace.ts src/provider/provider.ts
-
-# 3. Rewrite import paths (script prints the exact command)
-rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
-
-# 4. Verify
-bun typecheck
-bun run test
-```
-
-**What changes on disk:**
-
-```
-# BEFORE
-provider/
-  provider.ts        ← 1709 lines, `export namespace Provider { ... }`
-
-# AFTER
-provider/
-  index.ts           ← NEW: `export * as Provider from "./provider"`
-  provider.ts        ← same file, namespace unwrapped to flat exports
-```
-
-**What changes in consumer code:**
-
-```ts
-// BEFORE
-import { Provider } from "../provider/provider"
-
-// AFTER — shorter path, same Provider object
-import { Provider } from "../provider"
-```
-
-All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
-stays identical.
-
-### Two cases the script handles
-
-**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
-
-- Rewrites the file in place (unwrap + dedent)
-- Creates `provider/index.ts` as the barrel
-- Import paths change: `"../provider/provider"` → `"../provider"`
-
-**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
-
-- Renames `index.ts` → `bus.ts` (kebab-case of namespace name)
-- Creates new `index.ts` as the barrel
-- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
-
-## Do I need to split errors/schemas into subfiles?
-
-**No.** Once you do the `export * as` conversion, the bundler can tree-shake
-individual exports within the file. If `cli/error.ts` only accesses
-`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
-doesn't reference `createAnthropic` and drops the AI SDK imports.
-
-Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
-organization** — smaller files are easier to read and review. But it's not
-required for tree-shaking. The `export * as` conversion alone is sufficient.
-
-The one case where subfile splitting provides extra tree-shake value is if an
-imported package has module-level side effects that the bundler can't prove are
-unused. In practice this is rare — most npm packages are side-effect-free — and
-adding `"sideEffects": []` to package.json handles the common cases.
-
-## Scope
-
-| Metric                                          | Count           |
-| ----------------------------------------------- | --------------- |
-| Files with `export namespace`                   | 106             |
-| Total namespace declarations                    | 118 (12 nested) |
-| Files with `NamedError.create` inside namespace | 15              |
-| Total error classes to extract                  | ~30             |
-| Files using `export * as` today                 | 0               |
-
-Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
-LLM-friendly but touches every import site, so it should be done module by
-module with type-checking between each step. Each module is an independent PR.
-
-## Rules for new code
-
-Going forward:
-
-- **No new `export namespace`**. Use a file with flat named exports and
-  `export * as` in the barrel.
-- Keep the service, layer, errors, schemas, and runtime wiring together in one
-  file if you want — that's fine now. The `export * as` barrel makes everything
-  individually shakeable regardless of file structure.
-- If a file grows large enough that it's hard to navigate, split by concern
-  (errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
-  bundler handles that.
-
-## Circular import rules
-
-Barrel files (`index.ts` with `export * as`) introduce circular import risks.
-These cause `ReferenceError: Cannot access 'X' before initialization` at
-runtime — not caught by the type checker.
-
-### Rule 1: Sibling files never import through their own barrel
-
-Files in the same directory must import directly from the source file, never
-through `"."` or `"@/<own-dir>"`:
-
-```ts
-// BAD — circular: index.ts re-exports both files, so A → index → B → index → A
-import { Sibling } from "."
-
-// GOOD — direct, no cycle
-import * as Sibling from "./sibling"
-```
-
-### Rule 2: Cross-directory imports must not form cycles through barrels
-
-If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and
-`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle:
-
-```
-lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
-```
-
-Fix by importing the specific file, breaking the cycle:
-
-```ts
-// In config/config.ts — import directly, not through the lsp barrel
-import * as LSPServer from "../lsp/server"
-```
-
-### Why the type checker doesn't catch this
-
-TypeScript resolves types lazily — it doesn't evaluate module-scope
-expressions. The `ReferenceError` only happens at runtime when a module-scope
-`const` or function call accesses a value from a circular dependency that
-hasn't finished initializing. The SDK build step (`bun run --conditions=browser
-./src/index.ts generate`) is the reliable way to catch these because it
-evaluates all modules eagerly.
-
-### How to verify
-
-After any namespace conversion, run:
-
-```bash
-cd packages/opencode
-bun run --conditions=browser ./src/index.ts generate
-```
-
-If this completes without `ReferenceError`, the module graph is safe.

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

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

+ 254 - 28
packages/opencode/specs/effect/schema.md

@@ -1,12 +1,19 @@
 # Schema migration
 
-Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
+Practical reference for migrating data types in `packages/opencode` from
+Zod-first definitions to Effect Schema with Zod compatibility shims.
 
 ## Goal
 
-Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
+Use Effect Schema as the source of truth for domain models, IDs, inputs,
+outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
+compatibility boundaries by exposing a `.zod` static derived from the Effect
+schema via `@/util/effect-zod`.
 
-Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
+The long-term driver is `specs/effect/http-api.md` — once the HTTP server
+moves to `@effect/platform`, every Schema-first DTO can flow through
+`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
+`effect-zod` walker plus every `.zod` static can be deleted.
 
 ## Preferred shapes
 
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
 }
 ```
 
-If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
+If the class cannot reference itself cleanly during initialization, use the
+two-step `withStatics` pattern:
 
 ```ts
-const _Info = Schema.Struct({
+export const Info = Schema.Struct({
   id: FooID,
   name: Schema.String,
-})
-
-export const Info = Object.assign(_Info, {
-  zod: zod(_Info),
-})
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
 ```
 
 ### Errors
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
 
 ### IDs and branded leaf types
 
-Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
+Keep branded/schema-backed IDs as Effect schemas and expose
+`static readonly zod` for compatibility when callers still expect Zod.
+
+### Refinements
+
+Reuse named refinements instead of re-spelling `z.number().int().positive()`
+in every schema. The `effect-zod` walker translates the Effect versions into
+the corresponding zod methods, so JSON Schema output (`type: integer`,
+`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
+
+```ts
+const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
+const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
+const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
+```
+
+See `test/util/effect-zod.test.ts` for the full set of translated checks.
 
 ## Compatibility rule
 
-During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
+During migration, route validators, tool parameters, and any existing
+Zod-based boundary should consume the derived `.zod` schema instead of
+maintaining a second hand-written Zod schema.
 
 The default should be:
 
 - Effect Schema owns the type
 - `.zod` exists only as a compatibility surface
-- new domain models should not start Zod-first unless there is a concrete boundary-specific need
+- new domain models should not start Zod-first unless there is a concrete
+  boundary-specific need
 
 ## When Zod can stay
 
 It is fine to keep a Zod-native schema temporarily when:
 
-- the type is only used at an HTTP or tool boundary
+- the type is only used at an HTTP or tool boundary and is not reused elsewhere
 - the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
 - the migration would force unrelated churn across a large call graph
 
-When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
+When this happens, prefer leaving a short note or TODO rather than silently
+creating a parallel schema source of truth.
+
+## Escape hatches
+
+The walker in `@/util/effect-zod` exposes three explicit escape hatches for
+cases the pure-Schema path cannot express. Each one stays in the codebase
+only as long as its upstream or local dependency requires it — inline
+comments document when each can be deleted.
+
+### `ZodOverride` annotation
+
+Replaces the entire derivation with a hand-crafted zod schema. Used when:
+
+- the target carries external `$ref` metadata (e.g.
+  `config/model-id.ts` points at `https://models.dev/...`)
+- the target is a zod-only schema that cannot yet be expressed as Schema
+  (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
+
+### `ZodPreprocess` annotation
+
+Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
+`config/permission.ts` to inject `__originalKeys` before parsing, because
+`Schema.StructWithRest` canonicalises output (known fields first, catchall
+after) and destroys the user's original property order — which permission
+rule precedence depends on.
+
+Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
+(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
+the `__originalKeys` hack can both be deleted.
+
+### Local `DeepMutable<T>` in `config/config.ts`
+
+`Schema.Struct` produces `readonly` types. Some consumer code (notably the
+`Config` service) mutates `Info` objects directly, so a readonly-stripping
+utility is needed when casting the derived zod schema's output type.
+
+`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
+`unknown` to `{}` in the fallback branch — a bug that affects any schema
+using `Schema.Record(String, Schema.Unknown)`.
+
+Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
+to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
+`Types.DeepMutable` used directly.
 
 ## Ordering
 
@@ -81,19 +147,179 @@ Migrate in this order:
 4. Service-local internal models
 5. Route and tool boundary validators that can switch to `.zod`
 
-This keeps shared types canonical first and makes boundary updates mostly mechanical.
-
-## Checklist
-
-- [ ] Shared `schema.ts` leaf models are Effect Schema-first
-- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
-- [ ] Domain errors use `Schema.TaggedErrorClass`
-- [ ] Migrated types expose `.zod` for back compatibility
-- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
-- [ ] New domain models default to Effect Schema first
+This keeps shared types canonical first and makes boundary updates mostly
+mechanical.
+
+## Progress tracker
+
+### `src/config/` ✅ complete
+
+All of `packages/opencode/src/config/` has been migrated. Files that still
+import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
+type annotations — the `export const <Info|Spec>` values are all Effect
+Schema at source.
+
+- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
+- [x] server, layout
+- [x] keybinds
+- [x] permission#Info
+- [x] agent
+- [x] config.ts root
+
+### `src/*/schema.ts` leaf modules
+
+These are the highest-priority next targets. Each is a small, self-contained
+schema module with a clear domain.
+
+- [ ] `src/control-plane/schema.ts`
+- [ ] `src/permission/schema.ts`
+- [ ] `src/project/schema.ts`
+- [ ] `src/provider/schema.ts`
+- [ ] `src/pty/schema.ts`
+- [ ] `src/question/schema.ts`
+- [ ] `src/session/schema.ts`
+- [ ] `src/sync/schema.ts`
+- [ ] `src/tool/schema.ts`
+
+### Session domain
+
+Major cluster. Message + event types flow through the SSE API and every SDK
+output, so byte-identical SDK surface is critical.
+
+- [ ] `src/session/compaction.ts`
+- [ ] `src/session/message-v2.ts`
+- [ ] `src/session/message.ts`
+- [ ] `src/session/prompt.ts`
+- [ ] `src/session/revert.ts`
+- [ ] `src/session/session.ts`
+- [ ] `src/session/status.ts`
+- [ ] `src/session/summary.ts`
+- [ ] `src/session/todo.ts`
+
+### Provider domain
+
+- [ ] `src/provider/auth.ts`
+- [ ] `src/provider/models.ts`
+- [ ] `src/provider/provider.ts`
+
+### Tool schemas
+
+Each tool declares its parameters via a zod schema. Tools are consumed by
+both the in-process runtime and the AI SDK's tool-calling layer, so the
+emitted JSON Schema must stay byte-identical.
+
+- [ ] `src/tool/apply_patch.ts`
+- [ ] `src/tool/bash.ts`
+- [ ] `src/tool/codesearch.ts`
+- [ ] `src/tool/edit.ts`
+- [ ] `src/tool/glob.ts`
+- [ ] `src/tool/grep.ts`
+- [ ] `src/tool/invalid.ts`
+- [ ] `src/tool/lsp.ts`
+- [ ] `src/tool/multiedit.ts`
+- [ ] `src/tool/plan.ts`
+- [ ] `src/tool/question.ts`
+- [ ] `src/tool/read.ts`
+- [ ] `src/tool/registry.ts`
+- [ ] `src/tool/skill.ts`
+- [ ] `src/tool/task.ts`
+- [ ] `src/tool/todo.ts`
+- [ ] `src/tool/tool.ts`
+- [ ] `src/tool/webfetch.ts`
+- [ ] `src/tool/websearch.ts`
+- [ ] `src/tool/write.ts`
+
+### HTTP route boundaries
+
+Every file in `src/server/routes/` uses hono-openapi with zod validators for
+route inputs/outputs. Migrating these individually is the last step; most
+will switch to `.zod` derived from the Schema-migrated domain types above,
+which means touching them is largely mechanical once the domain side is
+done.
+
+- [ ] `src/server/error.ts`
+- [ ] `src/server/event.ts`
+- [ ] `src/server/projectors.ts`
+- [ ] `src/server/routes/control/index.ts`
+- [ ] `src/server/routes/control/workspace.ts`
+- [ ] `src/server/routes/global.ts`
+- [ ] `src/server/routes/instance/index.ts`
+- [ ] `src/server/routes/instance/config.ts`
+- [ ] `src/server/routes/instance/event.ts`
+- [ ] `src/server/routes/instance/experimental.ts`
+- [ ] `src/server/routes/instance/file.ts`
+- [ ] `src/server/routes/instance/mcp.ts`
+- [ ] `src/server/routes/instance/permission.ts`
+- [ ] `src/server/routes/instance/project.ts`
+- [ ] `src/server/routes/instance/provider.ts`
+- [ ] `src/server/routes/instance/pty.ts`
+- [ ] `src/server/routes/instance/question.ts`
+- [ ] `src/server/routes/instance/session.ts`
+- [ ] `src/server/routes/instance/sync.ts`
+- [ ] `src/server/routes/instance/tui.ts`
+
+The bigger prize for this group is the `@effect/platform` HTTP migration
+described in `specs/effect/http-api.md`. Once that lands, every one of
+these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
+so the Schema-first domain types become a prerequisite rather than a
+sibling task.
+
+### Everything else
+
+Small / shared / control-plane / CLI. Mostly independent; can be done
+piecewise.
+
+- [ ] `src/acp/agent.ts`
+- [ ] `src/agent/agent.ts`
+- [ ] `src/bus/bus-event.ts`
+- [ ] `src/bus/index.ts`
+- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
+- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
+- [ ] `src/cli/cmd/tui/config/tui.ts`
+- [ ] `src/cli/cmd/tui/event.ts`
+- [ ] `src/cli/ui.ts`
+- [ ] `src/command/index.ts`
+- [ ] `src/control-plane/adaptors/worktree.ts`
+- [ ] `src/control-plane/types.ts`
+- [ ] `src/control-plane/workspace.ts`
+- [ ] `src/file/index.ts`
+- [ ] `src/file/ripgrep.ts`
+- [ ] `src/file/watcher.ts`
+- [ ] `src/format/index.ts`
+- [ ] `src/id/id.ts`
+- [ ] `src/ide/index.ts`
+- [ ] `src/installation/index.ts`
+- [ ] `src/lsp/client.ts`
+- [ ] `src/lsp/lsp.ts`
+- [ ] `src/mcp/auth.ts`
+- [ ] `src/patch/index.ts`
+- [ ] `src/plugin/github-copilot/models.ts`
+- [ ] `src/project/project.ts`
+- [ ] `src/project/vcs.ts`
+- [ ] `src/pty/index.ts`
+- [ ] `src/skill/index.ts`
+- [ ] `src/snapshot/index.ts`
+- [ ] `src/storage/db.ts`
+- [ ] `src/storage/storage.ts`
+- [ ] `src/sync/index.ts`
+- [ ] `src/util/fn.ts`
+- [ ] `src/util/log.ts`
+- [ ] `src/util/update-schema.ts`
+- [ ] `src/worktree/index.ts`
+
+### Do-not-migrate
+
+- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
+  (it's what emits zod from Schema). Goes away only when the `.zod`
+  compatibility layer is no longer needed anywhere.
 
 ## Notes
 
-- Use `@/util/effect-zod` for all Schema -> Zod conversion.
-- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
-- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
+- Use `@/util/effect-zod` for all Schema → Zod conversion.
+- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
+  Effect definitions for the same domain type.
+- Keep the migration incremental. Converting the domain model first is more
+  valuable than converting every boundary in the same change.
+- Every migrated file should leave the generated SDK output (`packages/sdk/
+openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
+  unless the change is deliberately user-visible.

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

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

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

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

+ 4 - 2
packages/opencode/src/account/account.ts

@@ -181,10 +181,10 @@ export interface Interface {
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
 
-export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
+export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const repo = yield* AccountRepo
+    const repo = yield* AccountRepo.Service
     const http = yield* HttpClient.HttpClient
     const httpRead = withTransientReadRetry(http)
     const httpOk = HttpClient.filterStatusOk(http)
@@ -452,3 +452,5 @@ export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpCli
 )
 
 export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
+
+export * as Account from "./account"

+ 0 - 24
packages/opencode/src/account/index.ts

@@ -1,24 +0,0 @@
-export * as Account from "./account"
-export {
-  AccountID,
-  type AccountError,
-  AccountRepoError,
-  AccountServiceError,
-  AccountTransportError,
-  AccessToken,
-  RefreshToken,
-  DeviceCode,
-  UserCode,
-  Info,
-  Org,
-  OrgID,
-  Login,
-  PollSuccess,
-  PollPending,
-  PollSlow,
-  PollExpired,
-  PollDenied,
-  PollError,
-  type PollResult,
-} from "./schema"
-export type { AccountOrgs, ActiveOrg } from "./account"

+ 140 - 140
packages/opencode/src/account/repo.ts

@@ -13,154 +13,154 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
 
 const ACCOUNT_STATE_ID = 1
 
-export namespace AccountRepo {
-  export interface Service {
-    readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
-    readonly list: () => Effect.Effect<Info[], AccountRepoError>
-    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
-    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
-    readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
-    readonly persistToken: (input: {
-      accountID: AccountID
-      accessToken: AccessToken
-      refreshToken: RefreshToken
-      expiry: Option.Option<number>
-    }) => Effect.Effect<void, AccountRepoError>
-    readonly persistAccount: (input: {
-      id: AccountID
-      email: string
-      url: string
-      accessToken: AccessToken
-      refreshToken: RefreshToken
-      expiry: number
-      orgID: Option.Option<OrgID>
-    }) => Effect.Effect<void, AccountRepoError>
-  }
+export interface Interface {
+  readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
+  readonly list: () => Effect.Effect<Info[], AccountRepoError>
+  readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
+  readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
+  readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
+  readonly persistToken: (input: {
+    accountID: AccountID
+    accessToken: AccessToken
+    refreshToken: RefreshToken
+    expiry: Option.Option<number>
+  }) => Effect.Effect<void, AccountRepoError>
+  readonly persistAccount: (input: {
+    id: AccountID
+    email: string
+    url: string
+    accessToken: AccessToken
+    refreshToken: RefreshToken
+    expiry: number
+    orgID: Option.Option<OrgID>
+  }) => Effect.Effect<void, AccountRepoError>
 }
 
-export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
-  static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
-    AccountRepo,
-    Effect.gen(function* () {
-      const decode = Schema.decodeUnknownSync(Info)
+export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
 
-      const query = <A>(f: DbTransactionCallback<A>) =>
-        Effect.try({
-          try: () => Database.use(f),
-          catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
-        })
+export const layer: Layer.Layer<Service> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const decode = Schema.decodeUnknownSync(Info)
 
-      const tx = <A>(f: DbTransactionCallback<A>) =>
-        Effect.try({
-          try: () => Database.transaction(f),
-          catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
-        })
+    const query = <A>(f: DbTransactionCallback<A>) =>
+      Effect.try({
+        try: () => Database.use(f),
+        catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
+      })
 
-      const current = (db: DbClient) => {
-        const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
-        if (!state?.active_account_id) return
-        const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
-        if (!account) return
-        return { ...account, active_org_id: state.active_org_id ?? null }
-      }
-
-      const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
-        const id = Option.getOrNull(orgID)
-        return db
-          .insert(AccountStateTable)
-          .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
-          .onConflictDoUpdate({
-            target: AccountStateTable.id,
-            set: { active_account_id: accountID, active_org_id: id },
-          })
+    const tx = <A>(f: DbTransactionCallback<A>) =>
+      Effect.try({
+        try: () => Database.transaction(f),
+        catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
+      })
+
+    const current = (db: DbClient) => {
+      const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
+      if (!state?.active_account_id) return
+      const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
+      if (!account) return
+      return { ...account, active_org_id: state.active_org_id ?? null }
+    }
+
+    const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
+      const id = Option.getOrNull(orgID)
+      return db
+        .insert(AccountStateTable)
+        .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
+        .onConflictDoUpdate({
+          target: AccountStateTable.id,
+          set: { active_account_id: accountID, active_org_id: id },
+        })
+        .run()
+    }
+
+    const active = Effect.fn("AccountRepo.active")(() =>
+      query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
+    )
+
+    const list = Effect.fn("AccountRepo.list")(() =>
+      query((db) =>
+        db
+          .select()
+          .from(AccountTable)
+          .all()
+          .map((row: AccountRow) => decode({ ...row, active_org_id: null })),
+      ),
+    )
+
+    const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
+      tx((db) => {
+        db.update(AccountStateTable)
+          .set({ active_account_id: null, active_org_id: null })
+          .where(eq(AccountStateTable.active_account_id, accountID))
           .run()
-      }
-
-      const active = Effect.fn("AccountRepo.active")(() =>
-        query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
-      )
-
-      const list = Effect.fn("AccountRepo.list")(() =>
-        query((db) =>
-          db
-            .select()
-            .from(AccountTable)
-            .all()
-            .map((row: AccountRow) => decode({ ...row, active_org_id: null })),
-        ),
-      )
-
-      const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
-        tx((db) => {
-          db.update(AccountStateTable)
-            .set({ active_account_id: null, active_org_id: null })
-            .where(eq(AccountStateTable.active_account_id, accountID))
-            .run()
-          db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
-        }).pipe(Effect.asVoid),
-      )
-
-      const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
-        query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
-      )
-
-      const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
-        query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
-          Effect.map(Option.fromNullishOr),
-        ),
-      )
-
-      const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
-        query((db) =>
-          db
-            .update(AccountTable)
-            .set({
-              access_token: input.accessToken,
-              refresh_token: input.refreshToken,
-              token_expiry: Option.getOrNull(input.expiry),
-            })
-            .where(eq(AccountTable.id, input.accountID))
-            .run(),
-        ).pipe(Effect.asVoid),
-      )
-
-      const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
-        tx((db) => {
-          const url = normalizeServerUrl(input.url)
-
-          db.insert(AccountTable)
-            .values({
-              id: input.id,
+        db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
+      }).pipe(Effect.asVoid),
+    )
+
+    const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
+      query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
+    )
+
+    const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
+      query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
+        Effect.map(Option.fromNullishOr),
+      ),
+    )
+
+    const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
+      query((db) =>
+        db
+          .update(AccountTable)
+          .set({
+            access_token: input.accessToken,
+            refresh_token: input.refreshToken,
+            token_expiry: Option.getOrNull(input.expiry),
+          })
+          .where(eq(AccountTable.id, input.accountID))
+          .run(),
+      ).pipe(Effect.asVoid),
+    )
+
+    const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
+      tx((db) => {
+        const url = normalizeServerUrl(input.url)
+
+        db.insert(AccountTable)
+          .values({
+            id: input.id,
+            email: input.email,
+            url,
+            access_token: input.accessToken,
+            refresh_token: input.refreshToken,
+            token_expiry: input.expiry,
+          })
+          .onConflictDoUpdate({
+            target: AccountTable.id,
+            set: {
               email: input.email,
               url,
               access_token: input.accessToken,
               refresh_token: input.refreshToken,
               token_expiry: input.expiry,
-            })
-            .onConflictDoUpdate({
-              target: AccountTable.id,
-              set: {
-                email: input.email,
-                url,
-                access_token: input.accessToken,
-                refresh_token: input.refreshToken,
-                token_expiry: input.expiry,
-              },
-            })
-            .run()
-          void state(db, input.id, input.orgID)
-        }).pipe(Effect.asVoid),
-      )
-
-      return AccountRepo.of({
-        active,
-        list,
-        remove,
-        use,
-        getRow,
-        persistToken,
-        persistAccount,
-      })
-    }),
-  )
-}
+            },
+          })
+          .run()
+        void state(db, input.id, input.orgID)
+      }).pipe(Effect.asVoid),
+    )
+
+    return Service.of({
+      active,
+      list,
+      remove,
+      use,
+      getRow,
+      persistToken,
+      persistAccount,
+    })
+  }),
+)
+
+export * as AccountRepo from "./repo"

+ 1520 - 1523
packages/opencode/src/acp/agent.ts

@@ -44,6 +44,7 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "@/installation"
 import { MessageV2 } from "@/session/message-v2"
 import { Config } from "@/config"
+import { ConfigMCP } from "@/config/mcp"
 import { Todo } from "@/session/todo"
 import { z } from "zod"
 import { LoadAPIKeyError } from "ai"
@@ -56,1785 +57,1781 @@ type ModelOption = { modelId: string; name: string }
 
 const DEFAULT_VARIANT_VALUE = "default"
 
-export namespace ACP {
-  const log = Log.create({ service: "acp-agent" })
-
-  async function getContextLimit(
-    sdk: OpencodeClient,
-    providerID: ProviderID,
-    modelID: ModelID,
-    directory: string,
-  ): Promise<number | null> {
-    const providers = await sdk.config
-      .providers({ directory })
-      .then((x) => x.data?.providers ?? [])
-      .catch((error) => {
-        log.error("failed to get providers for context limit", { error })
-        return []
-      })
+const log = Log.create({ service: "acp-agent" })
+
+async function getContextLimit(
+  sdk: OpencodeClient,
+  providerID: ProviderID,
+  modelID: ModelID,
+  directory: string,
+): Promise<number | null> {
+  const providers = await sdk.config
+    .providers({ directory })
+    .then((x) => x.data?.providers ?? [])
+    .catch((error) => {
+      log.error("failed to get providers for context limit", { error })
+      return []
+    })
 
-    const provider = providers.find((p) => p.id === providerID)
-    const model = provider?.models[modelID]
-    return model?.limit.context ?? null
-  }
+  const provider = providers.find((p) => p.id === providerID)
+  const model = provider?.models[modelID]
+  return model?.limit.context ?? null
+}
 
-  async function sendUsageUpdate(
-    connection: AgentSideConnection,
-    sdk: OpencodeClient,
-    sessionID: string,
-    directory: string,
-  ): Promise<void> {
-    const messages = await sdk.session
-      .messages({ sessionID, directory }, { throwOnError: true })
-      .then((x) => x.data)
-      .catch((error) => {
-        log.error("failed to fetch messages for usage update", { error })
-        return undefined
-      })
+async function sendUsageUpdate(
+  connection: AgentSideConnection,
+  sdk: OpencodeClient,
+  sessionID: string,
+  directory: string,
+): Promise<void> {
+  const messages = await sdk.session
+    .messages({ sessionID, directory }, { throwOnError: true })
+    .then((x) => x.data)
+    .catch((error) => {
+      log.error("failed to fetch messages for usage update", { error })
+      return undefined
+    })
 
-    if (!messages) return
+  if (!messages) return
 
-    const assistantMessages = messages.filter(
-      (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant",
-    )
+  const assistantMessages = messages.filter(
+    (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant",
+  )
 
-    const lastAssistant = assistantMessages[assistantMessages.length - 1]
-    if (!lastAssistant) return
+  const lastAssistant = assistantMessages[assistantMessages.length - 1]
+  if (!lastAssistant) return
 
-    const msg = lastAssistant.info
-    if (!msg.providerID || !msg.modelID) return
-    const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory)
+  const msg = lastAssistant.info
+  if (!msg.providerID || !msg.modelID) return
+  const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory)
 
-    if (!size) {
-      // Cannot calculate usage without known context size
-      return
-    }
+  if (!size) {
+    // Cannot calculate usage without known context size
+    return
+  }
 
-    const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0)
-    const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0)
+  const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0)
+  const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0)
+
+  await connection
+    .sessionUpdate({
+      sessionId: sessionID,
+      update: {
+        sessionUpdate: "usage_update",
+        used,
+        size,
+        cost: { amount: totalCost, currency: "USD" },
+      },
+    })
+    .catch((error) => {
+      log.error("failed to send usage update", { error })
+    })
+}
 
-    await connection
-      .sessionUpdate({
-        sessionId: sessionID,
-        update: {
-          sessionUpdate: "usage_update",
-          used,
-          size,
-          cost: { amount: totalCost, currency: "USD" },
-        },
-      })
-      .catch((error) => {
-        log.error("failed to send usage update", { error })
-      })
+export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
+  return {
+    create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
+      return new Agent(connection, fullConfig)
+    },
   }
+}
 
-  export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
-    return {
-      create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
-        return new Agent(connection, fullConfig)
-      },
-    }
+export class Agent implements ACPAgent {
+  private connection: AgentSideConnection
+  private config: ACPConfig
+  private sdk: OpencodeClient
+  private sessionManager: ACPSessionManager
+  private eventAbort = new AbortController()
+  private eventStarted = false
+  private bashSnapshots = new Map<string, string>()
+  private toolStarts = new Set<string>()
+  private permissionQueues = new Map<string, Promise<void>>()
+  private permissionOptions: PermissionOption[] = [
+    { optionId: "once", kind: "allow_once", name: "Allow once" },
+    { optionId: "always", kind: "allow_always", name: "Always allow" },
+    { optionId: "reject", kind: "reject_once", name: "Reject" },
+  ]
+
+  constructor(connection: AgentSideConnection, config: ACPConfig) {
+    this.connection = connection
+    this.config = config
+    this.sdk = config.sdk
+    this.sessionManager = new ACPSessionManager(this.sdk)
+    this.startEventSubscription()
   }
 
-  export class Agent implements ACPAgent {
-    private connection: AgentSideConnection
-    private config: ACPConfig
-    private sdk: OpencodeClient
-    private sessionManager: ACPSessionManager
-    private eventAbort = new AbortController()
-    private eventStarted = false
-    private bashSnapshots = new Map<string, string>()
-    private toolStarts = new Set<string>()
-    private permissionQueues = new Map<string, Promise<void>>()
-    private permissionOptions: PermissionOption[] = [
-      { optionId: "once", kind: "allow_once", name: "Allow once" },
-      { optionId: "always", kind: "allow_always", name: "Always allow" },
-      { optionId: "reject", kind: "reject_once", name: "Reject" },
-    ]
-
-    constructor(connection: AgentSideConnection, config: ACPConfig) {
-      this.connection = connection
-      this.config = config
-      this.sdk = config.sdk
-      this.sessionManager = new ACPSessionManager(this.sdk)
-      this.startEventSubscription()
-    }
+  private startEventSubscription() {
+    if (this.eventStarted) return
+    this.eventStarted = true
+    this.runEventSubscription().catch((error) => {
+      if (this.eventAbort.signal.aborted) return
+      log.error("event subscription failed", { error })
+    })
+  }
 
-    private startEventSubscription() {
-      if (this.eventStarted) return
-      this.eventStarted = true
-      this.runEventSubscription().catch((error) => {
-        if (this.eventAbort.signal.aborted) return
-        log.error("event subscription failed", { error })
+  private async runEventSubscription() {
+    while (true) {
+      if (this.eventAbort.signal.aborted) return
+      const events = await this.sdk.global.event({
+        signal: this.eventAbort.signal,
       })
-    }
-
-    private async runEventSubscription() {
-      while (true) {
+      for await (const event of events.stream) {
         if (this.eventAbort.signal.aborted) return
-        const events = await this.sdk.global.event({
-          signal: this.eventAbort.signal,
+        const payload = event?.payload
+        if (!payload) continue
+        await this.handleEvent(payload as Event).catch((error) => {
+          log.error("failed to handle event", { error, type: payload.type })
         })
-        for await (const event of events.stream) {
-          if (this.eventAbort.signal.aborted) return
-          const payload = (event as any)?.payload
-          if (!payload) continue
-          await this.handleEvent(payload as Event).catch((error) => {
-            log.error("failed to handle event", { error, type: payload.type })
-          })
-        }
       }
     }
+  }
 
-    private async handleEvent(event: Event) {
-      switch (event.type) {
-        case "permission.asked": {
-          const permission = event.properties
-          const session = this.sessionManager.tryGet(permission.sessionID)
-          if (!session) return
-
-          const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
-          const next = prev
-            .then(async () => {
-              const directory = session.cwd
-
-              const res = await this.connection
-                .requestPermission({
-                  sessionId: permission.sessionID,
-                  toolCall: {
-                    toolCallId: permission.tool?.callID ?? permission.id,
-                    status: "pending",
-                    title: permission.permission,
-                    rawInput: permission.metadata,
-                    kind: toToolKind(permission.permission),
-                    locations: toLocations(permission.permission, permission.metadata),
-                  },
-                  options: this.permissionOptions,
-                })
-                .catch(async (error) => {
-                  log.error("failed to request permission from ACP", {
-                    error,
-                    permissionID: permission.id,
-                    sessionID: permission.sessionID,
-                  })
-                  await this.sdk.permission.reply({
-                    requestID: permission.id,
-                    reply: "reject",
-                    directory,
-                  })
-                  return undefined
+  private async handleEvent(event: Event) {
+    switch (event.type) {
+      case "permission.asked": {
+        const permission = event.properties
+        const session = this.sessionManager.tryGet(permission.sessionID)
+        if (!session) return
+
+        const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
+        const next = prev
+          .then(async () => {
+            const directory = session.cwd
+
+            const res = await this.connection
+              .requestPermission({
+                sessionId: permission.sessionID,
+                toolCall: {
+                  toolCallId: permission.tool?.callID ?? permission.id,
+                  status: "pending",
+                  title: permission.permission,
+                  rawInput: permission.metadata,
+                  kind: toToolKind(permission.permission),
+                  locations: toLocations(permission.permission, permission.metadata),
+                },
+                options: this.permissionOptions,
+              })
+              .catch(async (error) => {
+                log.error("failed to request permission from ACP", {
+                  error,
+                  permissionID: permission.id,
+                  sessionID: permission.sessionID,
                 })
-
-              if (!res) return
-              if (res.outcome.outcome !== "selected") {
                 await this.sdk.permission.reply({
                   requestID: permission.id,
                   reply: "reject",
                   directory,
                 })
-                return
-              }
-
-              if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
-                const metadata = permission.metadata || {}
-                const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
-                const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
-                const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
-                const newContent = getNewContent(content, diff)
-
-                if (newContent) {
-                  void this.connection.writeTextFile({
-                    sessionId: session.id,
-                    path: filepath,
-                    content: newContent,
-                  })
-                }
-              }
+                return undefined
+              })
 
+            if (!res) return
+            if (res.outcome.outcome !== "selected") {
               await this.sdk.permission.reply({
                 requestID: permission.id,
-                reply: res.outcome.optionId as "once" | "always" | "reject",
+                reply: "reject",
                 directory,
               })
-            })
-            .catch((error) => {
-              log.error("failed to handle permission", { error, permissionID: permission.id })
-            })
-            .finally(() => {
-              if (this.permissionQueues.get(permission.sessionID) === next) {
-                this.permissionQueues.delete(permission.sessionID)
+              return
+            }
+
+            if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
+              const metadata = permission.metadata || {}
+              const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
+              const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
+              const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
+              const newContent = getNewContent(content, diff)
+
+              if (newContent) {
+                void this.connection.writeTextFile({
+                  sessionId: session.id,
+                  path: filepath,
+                  content: newContent,
+                })
               }
+            }
+
+            await this.sdk.permission.reply({
+              requestID: permission.id,
+              reply: res.outcome.optionId as "once" | "always" | "reject",
+              directory,
             })
-          this.permissionQueues.set(permission.sessionID, next)
-          return
-        }
+          })
+          .catch((error) => {
+            log.error("failed to handle permission", { error, permissionID: permission.id })
+          })
+          .finally(() => {
+            if (this.permissionQueues.get(permission.sessionID) === next) {
+              this.permissionQueues.delete(permission.sessionID)
+            }
+          })
+        this.permissionQueues.set(permission.sessionID, next)
+        return
+      }
 
-        case "message.part.updated": {
-          log.info("message part updated", { event: event.properties })
-          const props = event.properties
-          const part = props.part
-          const session = this.sessionManager.tryGet(part.sessionID)
-          if (!session) return
-          const sessionId = session.id
-
-          if (part.type === "tool") {
-            await this.toolStart(sessionId, part)
-
-            switch (part.state.status) {
-              case "pending":
-                this.bashSnapshots.delete(part.callID)
-                return
-
-              case "running":
-                const output = this.bashOutput(part)
-                const content: ToolCallContent[] = []
-                if (output) {
-                  const hash = Hash.fast(output)
-                  if (part.tool === "bash") {
-                    if (this.bashSnapshots.get(part.callID) === hash) {
-                      await this.connection
-                        .sessionUpdate({
-                          sessionId,
-                          update: {
-                            sessionUpdate: "tool_call_update",
-                            toolCallId: part.callID,
-                            status: "in_progress",
-                            kind: toToolKind(part.tool),
-                            title: part.tool,
-                            locations: toLocations(part.tool, part.state.input),
-                            rawInput: part.state.input,
-                          },
-                        })
-                        .catch((error) => {
-                          log.error("failed to send tool in_progress to ACP", { error })
-                        })
-                      return
-                    }
-                    this.bashSnapshots.set(part.callID, hash)
-                  }
-                  content.push({
-                    type: "content",
-                    content: {
-                      type: "text",
-                      text: output,
-                    },
-                  })
-                }
-                await this.connection
-                  .sessionUpdate({
-                    sessionId,
-                    update: {
-                      sessionUpdate: "tool_call_update",
-                      toolCallId: part.callID,
-                      status: "in_progress",
-                      kind: toToolKind(part.tool),
-                      title: part.tool,
-                      locations: toLocations(part.tool, part.state.input),
-                      rawInput: part.state.input,
-                      ...(content.length > 0 && { content }),
-                    },
-                  })
-                  .catch((error) => {
-                    log.error("failed to send tool in_progress to ACP", { error })
-                  })
-                return
-
-              case "completed": {
-                this.toolStarts.delete(part.callID)
-                this.bashSnapshots.delete(part.callID)
-                const kind = toToolKind(part.tool)
-                const content: ToolCallContent[] = [
-                  {
-                    type: "content",
-                    content: {
-                      type: "text",
-                      text: part.state.output,
-                    },
-                  },
-                ]
-
-                if (kind === "edit") {
-                  const input = part.state.input
-                  const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
-                  const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
-                  const newText =
-                    typeof input["newString"] === "string"
-                      ? input["newString"]
-                      : typeof input["content"] === "string"
-                        ? input["content"]
-                        : ""
-                  content.push({
-                    type: "diff",
-                    path: filePath,
-                    oldText,
-                    newText,
-                  })
-                }
+      case "message.part.updated": {
+        log.info("message part updated", { event: event.properties })
+        const props = event.properties
+        const part = props.part
+        const session = this.sessionManager.tryGet(part.sessionID)
+        if (!session) return
+        const sessionId = session.id
+
+        if (part.type === "tool") {
+          await this.toolStart(sessionId, part)
+
+          switch (part.state.status) {
+            case "pending":
+              this.bashSnapshots.delete(part.callID)
+              return
 
-                if (part.tool === "todowrite") {
-                  const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
-                  if (parsedTodos.success) {
+            case "running":
+              const output = this.bashOutput(part)
+              const content: ToolCallContent[] = []
+              if (output) {
+                const hash = Hash.fast(output)
+                if (part.tool === "bash") {
+                  if (this.bashSnapshots.get(part.callID) === hash) {
                     await this.connection
                       .sessionUpdate({
                         sessionId,
                         update: {
-                          sessionUpdate: "plan",
-                          entries: parsedTodos.data.map((todo) => {
-                            const status: PlanEntry["status"] =
-                              todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
-                            return {
-                              priority: "medium",
-                              status,
-                              content: todo.content,
-                            }
-                          }),
+                          sessionUpdate: "tool_call_update",
+                          toolCallId: part.callID,
+                          status: "in_progress",
+                          kind: toToolKind(part.tool),
+                          title: part.tool,
+                          locations: toLocations(part.tool, part.state.input),
+                          rawInput: part.state.input,
                         },
                       })
                       .catch((error) => {
-                        log.error("failed to send session update for todo", { error })
+                        log.error("failed to send tool in_progress to ACP", { error })
                       })
-                  } else {
-                    log.error("failed to parse todo output", { error: parsedTodos.error })
+                    return
                   }
+                  this.bashSnapshots.set(part.callID, hash)
                 }
+                content.push({
+                  type: "content",
+                  content: {
+                    type: "text",
+                    text: output,
+                  },
+                })
+              }
+              await this.connection
+                .sessionUpdate({
+                  sessionId,
+                  update: {
+                    sessionUpdate: "tool_call_update",
+                    toolCallId: part.callID,
+                    status: "in_progress",
+                    kind: toToolKind(part.tool),
+                    title: part.tool,
+                    locations: toLocations(part.tool, part.state.input),
+                    rawInput: part.state.input,
+                    ...(content.length > 0 && { content }),
+                  },
+                })
+                .catch((error) => {
+                  log.error("failed to send tool in_progress to ACP", { error })
+                })
+              return
 
-                await this.connection
-                  .sessionUpdate({
-                    sessionId,
-                    update: {
-                      sessionUpdate: "tool_call_update",
-                      toolCallId: part.callID,
-                      status: "completed",
-                      kind,
-                      content,
-                      title: part.state.title,
-                      rawInput: part.state.input,
-                      rawOutput: {
-                        output: part.state.output,
-                        metadata: part.state.metadata,
+            case "completed": {
+              this.toolStarts.delete(part.callID)
+              this.bashSnapshots.delete(part.callID)
+              const kind = toToolKind(part.tool)
+              const content: ToolCallContent[] = [
+                {
+                  type: "content",
+                  content: {
+                    type: "text",
+                    text: part.state.output,
+                  },
+                },
+              ]
+
+              if (kind === "edit") {
+                const input = part.state.input
+                const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
+                const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
+                const newText =
+                  typeof input["newString"] === "string"
+                    ? input["newString"]
+                    : typeof input["content"] === "string"
+                      ? input["content"]
+                      : ""
+                content.push({
+                  type: "diff",
+                  path: filePath,
+                  oldText,
+                  newText,
+                })
+              }
+
+              if (part.tool === "todowrite") {
+                const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
+                if (parsedTodos.success) {
+                  await this.connection
+                    .sessionUpdate({
+                      sessionId,
+                      update: {
+                        sessionUpdate: "plan",
+                        entries: parsedTodos.data.map((todo) => {
+                          const status: PlanEntry["status"] =
+                            todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
+                          return {
+                            priority: "medium",
+                            status,
+                            content: todo.content,
+                          }
+                        }),
                       },
-                    },
-                  })
-                  .catch((error) => {
-                    log.error("failed to send tool completed to ACP", { error })
-                  })
-                return
+                    })
+                    .catch((error) => {
+                      log.error("failed to send session update for todo", { error })
+                    })
+                } else {
+                  log.error("failed to parse todo output", { error: parsedTodos.error })
+                }
               }
-              case "error":
-                this.toolStarts.delete(part.callID)
-                this.bashSnapshots.delete(part.callID)
-                await this.connection
-                  .sessionUpdate({
-                    sessionId,
-                    update: {
-                      sessionUpdate: "tool_call_update",
-                      toolCallId: part.callID,
-                      status: "failed",
-                      kind: toToolKind(part.tool),
-                      title: part.tool,
-                      rawInput: part.state.input,
-                      content: [
-                        {
-                          type: "content",
-                          content: {
-                            type: "text",
-                            text: part.state.error,
-                          },
+
+              await this.connection
+                .sessionUpdate({
+                  sessionId,
+                  update: {
+                    sessionUpdate: "tool_call_update",
+                    toolCallId: part.callID,
+                    status: "completed",
+                    kind,
+                    content,
+                    title: part.state.title,
+                    rawInput: part.state.input,
+                    rawOutput: {
+                      output: part.state.output,
+                      metadata: part.state.metadata,
+                    },
+                  },
+                })
+                .catch((error) => {
+                  log.error("failed to send tool completed to ACP", { error })
+                })
+              return
+            }
+            case "error":
+              this.toolStarts.delete(part.callID)
+              this.bashSnapshots.delete(part.callID)
+              await this.connection
+                .sessionUpdate({
+                  sessionId,
+                  update: {
+                    sessionUpdate: "tool_call_update",
+                    toolCallId: part.callID,
+                    status: "failed",
+                    kind: toToolKind(part.tool),
+                    title: part.tool,
+                    rawInput: part.state.input,
+                    content: [
+                      {
+                        type: "content",
+                        content: {
+                          type: "text",
+                          text: part.state.error,
                         },
-                      ],
-                      rawOutput: {
-                        error: part.state.error,
-                        metadata: part.state.metadata,
                       },
+                    ],
+                    rawOutput: {
+                      error: part.state.error,
+                      metadata: part.state.metadata,
                     },
-                  })
-                  .catch((error) => {
-                    log.error("failed to send tool error to ACP", { error })
-                  })
-                return
-            }
+                  },
+                })
+                .catch((error) => {
+                  log.error("failed to send tool error to ACP", { error })
+                })
+              return
           }
+        }
+
+        // ACP clients already know the prompt they just submitted, so replaying
+        // live user parts duplicates the message. We still replay user history in
+        // loadSession() and forkSession() via processMessage().
+        if (part.type !== "text" && part.type !== "file") return
+
+        return
+      }
+
+      case "message.part.delta": {
+        const props = event.properties
+        const session = this.sessionManager.tryGet(props.sessionID)
+        if (!session) return
+        const sessionId = session.id
 
-          // ACP clients already know the prompt they just submitted, so replaying
-          // live user parts duplicates the message. We still replay user history in
-          // loadSession() and forkSession() via processMessage().
-          if (part.type !== "text" && part.type !== "file") return
+        const message = await this.sdk.session
+          .message(
+            {
+              sessionID: props.sessionID,
+              messageID: props.messageID,
+              directory: session.cwd,
+            },
+            { throwOnError: true },
+          )
+          .then((x) => x.data)
+          .catch((error) => {
+            log.error("unexpected error when fetching message", { error })
+            return undefined
+          })
 
+        if (!message || message.info.role !== "assistant") return
+
+        const part = message.parts.find((p) => p.id === props.partID)
+        if (!part) return
+
+        if (part.type === "text" && props.field === "text" && part.ignored !== true) {
+          await this.connection
+            .sessionUpdate({
+              sessionId,
+              update: {
+                sessionUpdate: "agent_message_chunk",
+                messageId: props.messageID,
+                content: {
+                  type: "text",
+                  text: props.delta,
+                },
+              },
+            })
+            .catch((error) => {
+              log.error("failed to send text delta to ACP", { error })
+            })
           return
         }
 
-        case "message.part.delta": {
-          const props = event.properties
-          const session = this.sessionManager.tryGet(props.sessionID)
-          if (!session) return
-          const sessionId = session.id
-
-          const message = await this.sdk.session
-            .message(
-              {
-                sessionID: props.sessionID,
-                messageID: props.messageID,
-                directory: session.cwd,
+        if (part.type === "reasoning" && props.field === "text") {
+          await this.connection
+            .sessionUpdate({
+              sessionId,
+              update: {
+                sessionUpdate: "agent_thought_chunk",
+                messageId: props.messageID,
+                content: {
+                  type: "text",
+                  text: props.delta,
+                },
               },
-              { throwOnError: true },
-            )
-            .then((x) => x.data)
+            })
             .catch((error) => {
-              log.error("unexpected error when fetching message", { error })
-              return undefined
+              log.error("failed to send reasoning delta to ACP", { error })
             })
+        }
+        return
+      }
+    }
+  }
 
-          if (!message || message.info.role !== "assistant") return
+  async initialize(params: InitializeRequest): Promise<InitializeResponse> {
+    log.info("initialize", { protocolVersion: params.protocolVersion })
 
-          const part = message.parts.find((p) => p.id === props.partID)
-          if (!part) return
+    const authMethod: AuthMethod = {
+      description: "Run `opencode auth login` in the terminal",
+      name: "Login with opencode",
+      id: "opencode-login",
+    }
 
-          if (part.type === "text" && props.field === "text" && part.ignored !== true) {
-            await this.connection
-              .sessionUpdate({
-                sessionId,
-                update: {
-                  sessionUpdate: "agent_message_chunk",
-                  messageId: props.messageID,
-                  content: {
-                    type: "text",
-                    text: props.delta,
-                  },
-                },
-              })
-              .catch((error) => {
-                log.error("failed to send text delta to ACP", { error })
-              })
-            return
-          }
-
-          if (part.type === "reasoning" && props.field === "text") {
-            await this.connection
-              .sessionUpdate({
-                sessionId,
-                update: {
-                  sessionUpdate: "agent_thought_chunk",
-                  messageId: props.messageID,
-                  content: {
-                    type: "text",
-                    text: props.delta,
-                  },
-                },
-              })
-              .catch((error) => {
-                log.error("failed to send reasoning delta to ACP", { error })
-              })
-          }
-          return
-        }
+    // If client supports terminal-auth capability, use that instead.
+    if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
+      authMethod._meta = {
+        "terminal-auth": {
+          command: "opencode",
+          args: ["auth", "login"],
+          label: "OpenCode Login",
+        },
       }
     }
 
-    async initialize(params: InitializeRequest): Promise<InitializeResponse> {
-      log.info("initialize", { protocolVersion: params.protocolVersion })
-
-      const authMethod: AuthMethod = {
-        description: "Run `opencode auth login` in the terminal",
-        name: "Login with opencode",
-        id: "opencode-login",
-      }
-
-      // If client supports terminal-auth capability, use that instead.
-      if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
-        authMethod._meta = {
-          "terminal-auth": {
-            command: "opencode",
-            args: ["auth", "login"],
-            label: "OpenCode Login",
-          },
-        }
-      }
-
-      return {
-        protocolVersion: 1,
-        agentCapabilities: {
-          loadSession: true,
-          mcpCapabilities: {
-            http: true,
-            sse: true,
-          },
-          promptCapabilities: {
-            embeddedContext: true,
-            image: true,
-          },
-          sessionCapabilities: {
-            fork: {},
-            list: {},
-            resume: {},
-          },
+    return {
+      protocolVersion: 1,
+      agentCapabilities: {
+        loadSession: true,
+        mcpCapabilities: {
+          http: true,
+          sse: true,
         },
-        authMethods: [authMethod],
-        agentInfo: {
-          name: "OpenCode",
-          version: InstallationVersion,
+        promptCapabilities: {
+          embeddedContext: true,
+          image: true,
         },
-      }
+        sessionCapabilities: {
+          fork: {},
+          list: {},
+          resume: {},
+        },
+      },
+      authMethods: [authMethod],
+      agentInfo: {
+        name: "OpenCode",
+        version: InstallationVersion,
+      },
     }
+  }
 
-    async authenticate(_params: AuthenticateRequest) {
-      throw new Error("Authentication not implemented")
-    }
+  async authenticate(_params: AuthenticateRequest) {
+    throw new Error("Authentication not implemented")
+  }
 
-    async newSession(params: NewSessionRequest) {
-      const directory = params.cwd
-      try {
-        const model = await defaultModel(this.config, directory)
+  async newSession(params: NewSessionRequest) {
+    const directory = params.cwd
+    try {
+      const model = await defaultModel(this.config, directory)
 
-        // Store ACP session state
-        const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
-        const sessionId = state.id
+      // Store ACP session state
+      const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
+      const sessionId = state.id
 
-        log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
+      log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
 
-        const load = await this.loadSessionMode({
-          cwd: directory,
-          mcpServers: params.mcpServers,
-          sessionId,
-        })
+      const load = await this.loadSessionMode({
+        cwd: directory,
+        mcpServers: params.mcpServers,
+        sessionId,
+      })
 
-        return {
-          sessionId,
-          configOptions: load.configOptions,
-          models: load.models,
-          modes: load.modes,
-          _meta: load._meta,
-        }
-      } catch (e) {
-        const error = MessageV2.fromError(e, {
-          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
-        })
-        if (LoadAPIKeyError.isInstance(error)) {
-          throw RequestError.authRequired()
-        }
-        throw e
+      return {
+        sessionId,
+        configOptions: load.configOptions,
+        models: load.models,
+        modes: load.modes,
+        _meta: load._meta,
+      }
+    } catch (e) {
+      const error = MessageV2.fromError(e, {
+        providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+      })
+      if (LoadAPIKeyError.isInstance(error)) {
+        throw RequestError.authRequired()
       }
+      throw e
     }
+  }
 
-    async loadSession(params: LoadSessionRequest) {
-      const directory = params.cwd
-      const sessionId = params.sessionId
+  async loadSession(params: LoadSessionRequest) {
+    const directory = params.cwd
+    const sessionId = params.sessionId
 
-      try {
-        const model = await defaultModel(this.config, directory)
+    try {
+      const model = await defaultModel(this.config, directory)
 
-        // Store ACP session state
-        await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
+      // Store ACP session state
+      await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
 
-        log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
+      log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
 
-        const result = await this.loadSessionMode({
-          cwd: directory,
-          mcpServers: params.mcpServers,
-          sessionId,
-        })
+      const result = await this.loadSessionMode({
+        cwd: directory,
+        mcpServers: params.mcpServers,
+        sessionId,
+      })
 
-        // Replay session history
-        const messages = await this.sdk.session
-          .messages(
-            {
-              sessionID: sessionId,
-              directory,
-            },
-            { throwOnError: true },
-          )
-          .then((x) => x.data)
-          .catch((err) => {
-            log.error("unexpected error when fetching message", { error: err })
-            return undefined
-          })
+      // Replay session history
+      const messages = await this.sdk.session
+        .messages(
+          {
+            sessionID: sessionId,
+            directory,
+          },
+          { throwOnError: true },
+        )
+        .then((x) => x.data)
+        .catch((err) => {
+          log.error("unexpected error when fetching message", { error: err })
+          return undefined
+        })
 
-        const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
-        if (lastUser?.role === "user") {
-          result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
-          this.sessionManager.setModel(sessionId, {
-            providerID: ProviderID.make(lastUser.model.providerID),
-            modelID: ModelID.make(lastUser.model.modelID),
-          })
-          if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
-            result.modes.currentModeId = lastUser.agent
-            this.sessionManager.setMode(sessionId, lastUser.agent)
-          }
-          result.configOptions = buildConfigOptions({
-            currentModelId: result.models.currentModelId,
-            availableModels: result.models.availableModels,
-            modes: result.modes,
-          })
+      const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
+      if (lastUser?.role === "user") {
+        result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
+        this.sessionManager.setModel(sessionId, {
+          providerID: ProviderID.make(lastUser.model.providerID),
+          modelID: ModelID.make(lastUser.model.modelID),
+        })
+        if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
+          result.modes.currentModeId = lastUser.agent
+          this.sessionManager.setMode(sessionId, lastUser.agent)
         }
+        result.configOptions = buildConfigOptions({
+          currentModelId: result.models.currentModelId,
+          availableModels: result.models.availableModels,
+          modes: result.modes,
+        })
+      }
 
-        for (const msg of messages ?? []) {
-          log.debug("replay message", msg)
-          await this.processMessage(msg)
-        }
+      for (const msg of messages ?? []) {
+        log.debug("replay message", msg)
+        await this.processMessage(msg)
+      }
 
-        await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
+      await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
 
-        return result
-      } catch (e) {
-        const error = MessageV2.fromError(e, {
-          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
-        })
-        if (LoadAPIKeyError.isInstance(error)) {
-          throw RequestError.authRequired()
-        }
-        throw e
+      return result
+    } catch (e) {
+      const error = MessageV2.fromError(e, {
+        providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+      })
+      if (LoadAPIKeyError.isInstance(error)) {
+        throw RequestError.authRequired()
       }
+      throw e
     }
+  }
 
-    async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
-      try {
-        const cursor = params.cursor ? Number(params.cursor) : undefined
-        const limit = 100
+  async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
+    try {
+      const cursor = params.cursor ? Number(params.cursor) : undefined
+      const limit = 100
 
-        const sessions = await this.sdk.session
-          .list(
-            {
-              directory: params.cwd ?? undefined,
-              roots: true,
-            },
-            { throwOnError: true },
-          )
-          .then((x) => x.data ?? [])
+      const sessions = await this.sdk.session
+        .list(
+          {
+            directory: params.cwd ?? undefined,
+            roots: true,
+          },
+          { throwOnError: true },
+        )
+        .then((x) => x.data ?? [])
 
-        const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
-        const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted
-        const page = filtered.slice(0, limit)
+      const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
+      const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted
+      const page = filtered.slice(0, limit)
 
-        const entries: SessionInfo[] = page.map((session) => ({
-          sessionId: session.id,
-          cwd: session.directory,
-          title: session.title,
-          updatedAt: new Date(session.time.updated).toISOString(),
-        }))
+      const entries: SessionInfo[] = page.map((session) => ({
+        sessionId: session.id,
+        cwd: session.directory,
+        title: session.title,
+        updatedAt: new Date(session.time.updated).toISOString(),
+      }))
 
-        const last = page[page.length - 1]
-        const next = filtered.length > limit && last ? String(last.time.updated) : undefined
+      const last = page[page.length - 1]
+      const next = filtered.length > limit && last ? String(last.time.updated) : undefined
 
-        const response: ListSessionsResponse = {
-          sessions: entries,
-        }
-        if (next) response.nextCursor = next
-        return response
-      } catch (e) {
-        const error = MessageV2.fromError(e, {
-          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
-        })
-        if (LoadAPIKeyError.isInstance(error)) {
-          throw RequestError.authRequired()
-        }
-        throw e
+      const response: ListSessionsResponse = {
+        sessions: entries,
+      }
+      if (next) response.nextCursor = next
+      return response
+    } catch (e) {
+      const error = MessageV2.fromError(e, {
+        providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+      })
+      if (LoadAPIKeyError.isInstance(error)) {
+        throw RequestError.authRequired()
       }
+      throw e
     }
+  }
 
-    async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
-      const directory = params.cwd
-      const mcpServers = params.mcpServers ?? []
+  async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
+    const directory = params.cwd
+    const mcpServers = params.mcpServers ?? []
 
-      try {
-        const model = await defaultModel(this.config, directory)
+    try {
+      const model = await defaultModel(this.config, directory)
 
-        const forked = await this.sdk.session
-          .fork(
-            {
-              sessionID: params.sessionId,
-              directory,
-            },
-            { throwOnError: true },
-          )
-          .then((x) => x.data)
+      const forked = await this.sdk.session
+        .fork(
+          {
+            sessionID: params.sessionId,
+            directory,
+          },
+          { throwOnError: true },
+        )
+        .then((x) => x.data)
 
-        if (!forked) {
-          throw new Error("Fork session returned no data")
-        }
+      if (!forked) {
+        throw new Error("Fork session returned no data")
+      }
 
-        const sessionId = forked.id
-        await this.sessionManager.load(sessionId, directory, mcpServers, model)
+      const sessionId = forked.id
+      await this.sessionManager.load(sessionId, directory, mcpServers, model)
 
-        log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
+      log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
 
-        const mode = await this.loadSessionMode({
-          cwd: directory,
-          mcpServers,
-          sessionId,
-        })
+      const mode = await this.loadSessionMode({
+        cwd: directory,
+        mcpServers,
+        sessionId,
+      })
 
-        const messages = await this.sdk.session
-          .messages(
-            {
-              sessionID: sessionId,
-              directory,
-            },
-            { throwOnError: true },
-          )
-          .then((x) => x.data)
-          .catch((err) => {
-            log.error("unexpected error when fetching message", { error: err })
-            return undefined
-          })
+      const messages = await this.sdk.session
+        .messages(
+          {
+            sessionID: sessionId,
+            directory,
+          },
+          { throwOnError: true },
+        )
+        .then((x) => x.data)
+        .catch((err) => {
+          log.error("unexpected error when fetching message", { error: err })
+          return undefined
+        })
 
-        for (const msg of messages ?? []) {
-          log.debug("replay message", msg)
-          await this.processMessage(msg)
-        }
+      for (const msg of messages ?? []) {
+        log.debug("replay message", msg)
+        await this.processMessage(msg)
+      }
 
-        await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
+      await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
 
-        return mode
-      } catch (e) {
-        const error = MessageV2.fromError(e, {
-          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
-        })
-        if (LoadAPIKeyError.isInstance(error)) {
-          throw RequestError.authRequired()
-        }
-        throw e
+      return mode
+    } catch (e) {
+      const error = MessageV2.fromError(e, {
+        providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+      })
+      if (LoadAPIKeyError.isInstance(error)) {
+        throw RequestError.authRequired()
       }
+      throw e
     }
+  }
 
-    async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
-      const directory = params.cwd
-      const sessionId = params.sessionId
-      const mcpServers = params.mcpServers ?? []
+  async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
+    const directory = params.cwd
+    const sessionId = params.sessionId
+    const mcpServers = params.mcpServers ?? []
 
-      try {
-        const model = await defaultModel(this.config, directory)
-        await this.sessionManager.load(sessionId, directory, mcpServers, model)
+    try {
+      const model = await defaultModel(this.config, directory)
+      await this.sessionManager.load(sessionId, directory, mcpServers, model)
 
-        log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
+      log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
 
-        const result = await this.loadSessionMode({
-          cwd: directory,
-          mcpServers,
-          sessionId,
-        })
+      const result = await this.loadSessionMode({
+        cwd: directory,
+        mcpServers,
+        sessionId,
+      })
 
-        await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
+      await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
 
-        return result
-      } catch (e) {
-        const error = MessageV2.fromError(e, {
-          providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
-        })
-        if (LoadAPIKeyError.isInstance(error)) {
-          throw RequestError.authRequired()
-        }
-        throw e
+      return result
+    } catch (e) {
+      const error = MessageV2.fromError(e, {
+        providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
+      })
+      if (LoadAPIKeyError.isInstance(error)) {
+        throw RequestError.authRequired()
       }
+      throw e
     }
+  }
 
-    private async processMessage(message: SessionMessageResponse) {
-      log.debug("process message", message)
-      if (message.info.role !== "assistant" && message.info.role !== "user") return
-      const sessionId = message.info.sessionID
-
-      for (const part of message.parts) {
-        if (part.type === "tool") {
-          await this.toolStart(sessionId, part)
-          switch (part.state.status) {
-            case "pending":
-              this.bashSnapshots.delete(part.callID)
-              break
-            case "running":
-              const output = this.bashOutput(part)
-              const runningContent: ToolCallContent[] = []
-              if (output) {
-                runningContent.push({
-                  type: "content",
-                  content: {
-                    type: "text",
-                    text: output,
-                  },
-                })
-              }
-              await this.connection
-                .sessionUpdate({
-                  sessionId,
-                  update: {
-                    sessionUpdate: "tool_call_update",
-                    toolCallId: part.callID,
-                    status: "in_progress",
-                    kind: toToolKind(part.tool),
-                    title: part.tool,
-                    locations: toLocations(part.tool, part.state.input),
-                    rawInput: part.state.input,
-                    ...(runningContent.length > 0 && { content: runningContent }),
-                  },
-                })
-                .catch((err) => {
-                  log.error("failed to send tool in_progress to ACP", { error: err })
-                })
-              break
-            case "completed":
-              this.toolStarts.delete(part.callID)
-              this.bashSnapshots.delete(part.callID)
-              const kind = toToolKind(part.tool)
-              const content: ToolCallContent[] = [
-                {
-                  type: "content",
-                  content: {
-                    type: "text",
-                    text: part.state.output,
-                  },
+  private async processMessage(message: SessionMessageResponse) {
+    log.debug("process message", message)
+    if (message.info.role !== "assistant" && message.info.role !== "user") return
+    const sessionId = message.info.sessionID
+
+    for (const part of message.parts) {
+      if (part.type === "tool") {
+        await this.toolStart(sessionId, part)
+        switch (part.state.status) {
+          case "pending":
+            this.bashSnapshots.delete(part.callID)
+            break
+          case "running":
+            const output = this.bashOutput(part)
+            const runningContent: ToolCallContent[] = []
+            if (output) {
+              runningContent.push({
+                type: "content",
+                content: {
+                  type: "text",
+                  text: output,
                 },
-              ]
-
-              if (kind === "edit") {
-                const input = part.state.input
-                const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
-                const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
-                const newText =
-                  typeof input["newString"] === "string"
-                    ? input["newString"]
-                    : typeof input["content"] === "string"
-                      ? input["content"]
-                      : ""
-                content.push({
-                  type: "diff",
-                  path: filePath,
-                  oldText,
-                  newText,
-                })
-              }
+              })
+            }
+            await this.connection
+              .sessionUpdate({
+                sessionId,
+                update: {
+                  sessionUpdate: "tool_call_update",
+                  toolCallId: part.callID,
+                  status: "in_progress",
+                  kind: toToolKind(part.tool),
+                  title: part.tool,
+                  locations: toLocations(part.tool, part.state.input),
+                  rawInput: part.state.input,
+                  ...(runningContent.length > 0 && { content: runningContent }),
+                },
+              })
+              .catch((err) => {
+                log.error("failed to send tool in_progress to ACP", { error: err })
+              })
+            break
+          case "completed":
+            this.toolStarts.delete(part.callID)
+            this.bashSnapshots.delete(part.callID)
+            const kind = toToolKind(part.tool)
+            const content: ToolCallContent[] = [
+              {
+                type: "content",
+                content: {
+                  type: "text",
+                  text: part.state.output,
+                },
+              },
+            ]
+
+            if (kind === "edit") {
+              const input = part.state.input
+              const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
+              const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
+              const newText =
+                typeof input["newString"] === "string"
+                  ? input["newString"]
+                  : typeof input["content"] === "string"
+                    ? input["content"]
+                    : ""
+              content.push({
+                type: "diff",
+                path: filePath,
+                oldText,
+                newText,
+              })
+            }
 
-              if (part.tool === "todowrite") {
-                const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
-                if (parsedTodos.success) {
-                  await this.connection
-                    .sessionUpdate({
-                      sessionId,
-                      update: {
-                        sessionUpdate: "plan",
-                        entries: parsedTodos.data.map((todo) => {
-                          const status: PlanEntry["status"] =
-                            todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
-                          return {
-                            priority: "medium",
-                            status,
-                            content: todo.content,
-                          }
-                        }),
-                      },
-                    })
-                    .catch((err) => {
-                      log.error("failed to send session update for todo", { error: err })
-                    })
-                } else {
-                  log.error("failed to parse todo output", { error: parsedTodos.error })
-                }
+            if (part.tool === "todowrite") {
+              const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
+              if (parsedTodos.success) {
+                await this.connection
+                  .sessionUpdate({
+                    sessionId,
+                    update: {
+                      sessionUpdate: "plan",
+                      entries: parsedTodos.data.map((todo) => {
+                        const status: PlanEntry["status"] =
+                          todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
+                        return {
+                          priority: "medium",
+                          status,
+                          content: todo.content,
+                        }
+                      }),
+                    },
+                  })
+                  .catch((err) => {
+                    log.error("failed to send session update for todo", { error: err })
+                  })
+              } else {
+                log.error("failed to parse todo output", { error: parsedTodos.error })
               }
+            }
 
-              await this.connection
-                .sessionUpdate({
-                  sessionId,
-                  update: {
-                    sessionUpdate: "tool_call_update",
-                    toolCallId: part.callID,
-                    status: "completed",
-                    kind,
-                    content,
-                    title: part.state.title,
-                    rawInput: part.state.input,
-                    rawOutput: {
-                      output: part.state.output,
-                      metadata: part.state.metadata,
-                    },
-                  },
-                })
-                .catch((err) => {
-                  log.error("failed to send tool completed to ACP", { error: err })
-                })
-              break
-            case "error":
-              this.toolStarts.delete(part.callID)
-              this.bashSnapshots.delete(part.callID)
-              await this.connection
-                .sessionUpdate({
-                  sessionId,
-                  update: {
-                    sessionUpdate: "tool_call_update",
-                    toolCallId: part.callID,
-                    status: "failed",
-                    kind: toToolKind(part.tool),
-                    title: part.tool,
-                    rawInput: part.state.input,
-                    content: [
-                      {
-                        type: "content",
-                        content: {
-                          type: "text",
-                          text: part.state.error,
-                        },
-                      },
-                    ],
-                    rawOutput: {
-                      error: part.state.error,
-                      metadata: part.state.metadata,
-                    },
-                  },
-                })
-                .catch((err) => {
-                  log.error("failed to send tool error to ACP", { error: err })
-                })
-              break
-          }
-        } else if (part.type === "text") {
-          if (part.text) {
-            const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
             await this.connection
               .sessionUpdate({
                 sessionId,
                 update: {
-                  sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
-                  messageId: message.info.id,
-                  content: {
-                    type: "text",
-                    text: part.text,
-                    ...(audience && { annotations: { audience } }),
+                  sessionUpdate: "tool_call_update",
+                  toolCallId: part.callID,
+                  status: "completed",
+                  kind,
+                  content,
+                  title: part.state.title,
+                  rawInput: part.state.input,
+                  rawOutput: {
+                    output: part.state.output,
+                    metadata: part.state.metadata,
                   },
                 },
               })
               .catch((err) => {
-                log.error("failed to send text to ACP", { error: err })
+                log.error("failed to send tool completed to ACP", { error: err })
               })
-          }
-        } else if (part.type === "file") {
-          // Replay file attachments as appropriate ACP content blocks.
-          // OpenCode stores files internally as { type: "file", url, filename, mime }.
-          // We convert these back to ACP blocks based on the URL scheme and MIME type:
-          // - file:// URLs → resource_link
-          // - data: URLs with image/* → image block
-          // - data: URLs with text/* or application/json → resource with text
-          // - data: URLs with other types → resource with blob
-          const url = part.url
-          const filename = part.filename ?? "file"
-          const mime = part.mime || "application/octet-stream"
-          const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
-
-          if (url.startsWith("file://")) {
-            // Local file reference - send as resource_link
+            break
+          case "error":
+            this.toolStarts.delete(part.callID)
+            this.bashSnapshots.delete(part.callID)
             await this.connection
               .sessionUpdate({
                 sessionId,
                 update: {
-                  sessionUpdate: messageChunk,
-                  messageId: message.info.id,
-                  content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
+                  sessionUpdate: "tool_call_update",
+                  toolCallId: part.callID,
+                  status: "failed",
+                  kind: toToolKind(part.tool),
+                  title: part.tool,
+                  rawInput: part.state.input,
+                  content: [
+                    {
+                      type: "content",
+                      content: {
+                        type: "text",
+                        text: part.state.error,
+                      },
+                    },
+                  ],
+                  rawOutput: {
+                    error: part.state.error,
+                    metadata: part.state.metadata,
+                  },
                 },
               })
               .catch((err) => {
-                log.error("failed to send resource_link to ACP", { error: err })
+                log.error("failed to send tool error to ACP", { error: err })
               })
-          } else if (url.startsWith("data:")) {
-            // Embedded content - parse data URL and send as appropriate block type
-            const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
-            const dataMime = base64Match?.[1]
-            const base64Data = base64Match?.[2] ?? ""
+            break
+        }
+      } else if (part.type === "text") {
+        if (part.text) {
+          const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
+          await this.connection
+            .sessionUpdate({
+              sessionId,
+              update: {
+                sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
+                messageId: message.info.id,
+                content: {
+                  type: "text",
+                  text: part.text,
+                  ...(audience && { annotations: { audience } }),
+                },
+              },
+            })
+            .catch((err) => {
+              log.error("failed to send text to ACP", { error: err })
+            })
+        }
+      } else if (part.type === "file") {
+        // Replay file attachments as appropriate ACP content blocks.
+        // OpenCode stores files internally as { type: "file", url, filename, mime }.
+        // We convert these back to ACP blocks based on the URL scheme and MIME type:
+        // - file:// URLs → resource_link
+        // - data: URLs with image/* → image block
+        // - data: URLs with text/* or application/json → resource with text
+        // - data: URLs with other types → resource with blob
+        const url = part.url
+        const filename = part.filename ?? "file"
+        const mime = part.mime || "application/octet-stream"
+        const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
+
+        if (url.startsWith("file://")) {
+          // Local file reference - send as resource_link
+          await this.connection
+            .sessionUpdate({
+              sessionId,
+              update: {
+                sessionUpdate: messageChunk,
+                messageId: message.info.id,
+                content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
+              },
+            })
+            .catch((err) => {
+              log.error("failed to send resource_link to ACP", { error: err })
+            })
+        } else if (url.startsWith("data:")) {
+          // Embedded content - parse data URL and send as appropriate block type
+          const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
+          const dataMime = base64Match?.[1]
+          const base64Data = base64Match?.[2] ?? ""
 
-            const effectiveMime = dataMime || mime
+          const effectiveMime = dataMime || mime
 
-            if (effectiveMime.startsWith("image/")) {
-              // Image - send as image block
-              await this.connection
-                .sessionUpdate({
-                  sessionId,
-                  update: {
-                    sessionUpdate: messageChunk,
-                    messageId: message.info.id,
-                    content: {
-                      type: "image",
-                      mimeType: effectiveMime,
-                      data: base64Data,
-                      uri: pathToFileURL(filename).href,
-                    },
-                  },
-                })
-                .catch((err) => {
-                  log.error("failed to send image to ACP", { error: err })
-                })
-            } else {
-              // Non-image: text types get decoded, binary types stay as blob
-              const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
-              const fileUri = pathToFileURL(filename).href
-              const resource = isText
-                ? {
-                    uri: fileUri,
-                    mimeType: effectiveMime,
-                    text: Buffer.from(base64Data, "base64").toString("utf-8"),
-                  }
-                : { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
-
-              await this.connection
-                .sessionUpdate({
-                  sessionId,
-                  update: {
-                    sessionUpdate: messageChunk,
-                    messageId: message.info.id,
-                    content: { type: "resource", resource },
-                  },
-                })
-                .catch((err) => {
-                  log.error("failed to send resource to ACP", { error: err })
-                })
-            }
-          }
-          // URLs that don't match file:// or data: are skipped (unsupported)
-        } else if (part.type === "reasoning") {
-          if (part.text) {
+          if (effectiveMime.startsWith("image/")) {
+            // Image - send as image block
             await this.connection
               .sessionUpdate({
                 sessionId,
                 update: {
-                  sessionUpdate: "agent_thought_chunk",
+                  sessionUpdate: messageChunk,
                   messageId: message.info.id,
                   content: {
-                    type: "text",
-                    text: part.text,
+                    type: "image",
+                    mimeType: effectiveMime,
+                    data: base64Data,
+                    uri: pathToFileURL(filename).href,
                   },
                 },
               })
               .catch((err) => {
-                log.error("failed to send reasoning to ACP", { error: err })
+                log.error("failed to send image to ACP", { error: err })
+              })
+          } else {
+            // Non-image: text types get decoded, binary types stay as blob
+            const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
+            const fileUri = pathToFileURL(filename).href
+            const resource = isText
+              ? {
+                  uri: fileUri,
+                  mimeType: effectiveMime,
+                  text: Buffer.from(base64Data, "base64").toString("utf-8"),
+                }
+              : { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
+
+            await this.connection
+              .sessionUpdate({
+                sessionId,
+                update: {
+                  sessionUpdate: messageChunk,
+                  messageId: message.info.id,
+                  content: { type: "resource", resource },
+                },
+              })
+              .catch((err) => {
+                log.error("failed to send resource to ACP", { error: err })
               })
           }
         }
-      }
-    }
-
-    private bashOutput(part: ToolPart) {
-      if (part.tool !== "bash") return
-      if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
-      const output = part.state.metadata["output"]
-      if (typeof output !== "string") return
-      return output
-    }
-
-    private async toolStart(sessionId: string, part: ToolPart) {
-      if (this.toolStarts.has(part.callID)) return
-      this.toolStarts.add(part.callID)
-      await this.connection
-        .sessionUpdate({
-          sessionId,
-          update: {
-            sessionUpdate: "tool_call",
-            toolCallId: part.callID,
-            title: part.tool,
-            kind: toToolKind(part.tool),
-            status: "pending",
-            locations: [],
-            rawInput: {},
-          },
-        })
-        .catch((error) => {
-          log.error("failed to send tool pending to ACP", { error })
-        })
-    }
-
-    private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
-      const agents = await this.config.sdk.app
-        .agents(
-          {
-            directory,
-          },
-          { throwOnError: true },
-        )
-        .then((resp) => resp.data!)
-
-      return agents
-        .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
-        .map((agent) => ({
-          id: agent.name,
-          name: agent.name,
-          description: agent.description,
-        }))
-    }
-
-    private async resolveModeState(
-      directory: string,
-      sessionId: string,
-    ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
-      const availableModes = await this.loadAvailableModes(directory)
-      const currentModeId =
-        this.sessionManager.get(sessionId).modeId ||
-        (await (async () => {
-          if (!availableModes.length) return undefined
-          const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
-          const resolvedModeId =
-            availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
-          this.sessionManager.setMode(sessionId, resolvedModeId)
-          return resolvedModeId
-        })())
-
-      return { availableModes, currentModeId }
+        // URLs that don't match file:// or data: are skipped (unsupported)
+      } else if (part.type === "reasoning") {
+        if (part.text) {
+          await this.connection
+            .sessionUpdate({
+              sessionId,
+              update: {
+                sessionUpdate: "agent_thought_chunk",
+                messageId: message.info.id,
+                content: {
+                  type: "text",
+                  text: part.text,
+                },
+              },
+            })
+            .catch((err) => {
+              log.error("failed to send reasoning to ACP", { error: err })
+            })
+        }
+      }
     }
+  }
 
-    private async loadSessionMode(params: LoadSessionRequest) {
-      const directory = params.cwd
-      const model = await defaultModel(this.config, directory)
-      const sessionId = params.sessionId
-
-      const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
-      const entries = sortProvidersByName(providers)
-      const availableVariants = modelVariantsFromProviders(entries, model)
-      const currentVariant = this.sessionManager.getVariant(sessionId)
-      if (currentVariant && !availableVariants.includes(currentVariant)) {
-        this.sessionManager.setVariant(sessionId, undefined)
-      }
-      const availableModels = buildAvailableModels(entries, { includeVariants: true })
-      const modeState = await this.resolveModeState(directory, sessionId)
-      const currentModeId = modeState.currentModeId
-      const modes = currentModeId
-        ? {
-            availableModes: modeState.availableModes,
-            currentModeId,
-          }
-        : undefined
+  private bashOutput(part: ToolPart) {
+    if (part.tool !== "bash") return
+    if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
+    const output = part.state.metadata["output"]
+    if (typeof output !== "string") return
+    return output
+  }
 
-      const commands = await this.config.sdk.command
-        .list(
-          {
-            directory,
-          },
-          { throwOnError: true },
-        )
-        .then((resp) => resp.data!)
+  private async toolStart(sessionId: string, part: ToolPart) {
+    if (this.toolStarts.has(part.callID)) return
+    this.toolStarts.add(part.callID)
+    await this.connection
+      .sessionUpdate({
+        sessionId,
+        update: {
+          sessionUpdate: "tool_call",
+          toolCallId: part.callID,
+          title: part.tool,
+          kind: toToolKind(part.tool),
+          status: "pending",
+          locations: [],
+          rawInput: {},
+        },
+      })
+      .catch((error) => {
+        log.error("failed to send tool pending to ACP", { error })
+      })
+  }
 
-      const availableCommands = commands.map((command) => ({
-        name: command.name,
-        description: command.description ?? "",
+  private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
+    const agents = await this.config.sdk.app
+      .agents(
+        {
+          directory,
+        },
+        { throwOnError: true },
+      )
+      .then((resp) => resp.data!)
+
+    return agents
+      .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
+      .map((agent) => ({
+        id: agent.name,
+        name: agent.name,
+        description: agent.description,
       }))
-      const names = new Set(availableCommands.map((c) => c.name))
-      if (!names.has("compact"))
-        availableCommands.push({
-          name: "compact",
-          description: "compact the session",
-        })
+  }
 
-      const mcpServers: Record<string, Config.Mcp> = {}
-      for (const server of params.mcpServers) {
-        if ("type" in server) {
-          mcpServers[server.name] = {
-            url: server.url,
-            headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
-              acc[name] = value
-              return acc
-            }, {}),
-            type: "remote",
-          }
-        } else {
-          mcpServers[server.name] = {
-            type: "local",
-            command: [server.command, ...server.args],
-            environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
-              acc[name] = value
-              return acc
-            }, {}),
-          }
+  private async resolveModeState(
+    directory: string,
+    sessionId: string,
+  ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
+    const availableModes = await this.loadAvailableModes(directory)
+    const currentModeId =
+      this.sessionManager.get(sessionId).modeId ||
+      (await (async () => {
+        if (!availableModes.length) return undefined
+        const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
+        const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
+        this.sessionManager.setMode(sessionId, resolvedModeId)
+        return resolvedModeId
+      })())
+
+    return { availableModes, currentModeId }
+  }
+
+  private async loadSessionMode(params: LoadSessionRequest) {
+    const directory = params.cwd
+    const model = await defaultModel(this.config, directory)
+    const sessionId = params.sessionId
+
+    const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
+    const entries = sortProvidersByName(providers)
+    const availableVariants = modelVariantsFromProviders(entries, model)
+    const currentVariant = this.sessionManager.getVariant(sessionId)
+    if (currentVariant && !availableVariants.includes(currentVariant)) {
+      this.sessionManager.setVariant(sessionId, undefined)
+    }
+    const availableModels = buildAvailableModels(entries, { includeVariants: true })
+    const modeState = await this.resolveModeState(directory, sessionId)
+    const currentModeId = modeState.currentModeId
+    const modes = currentModeId
+      ? {
+          availableModes: modeState.availableModes,
+          currentModeId,
         }
-      }
+      : undefined
 
-      await Promise.all(
-        Object.entries(mcpServers).map(async ([key, mcp]) => {
-          await this.sdk.mcp
-            .add(
-              {
-                directory,
-                name: key,
-                config: mcp,
-              },
-              { throwOnError: true },
-            )
-            .catch((error) => {
-              log.error("failed to add mcp server", { name: key, error })
-            })
-        }),
+    const commands = await this.config.sdk.command
+      .list(
+        {
+          directory,
+        },
+        { throwOnError: true },
       )
+      .then((resp) => resp.data!)
+
+    const availableCommands = commands.map((command) => ({
+      name: command.name,
+      description: command.description ?? "",
+    }))
+    const names = new Set(availableCommands.map((c) => c.name))
+    if (!names.has("compact"))
+      availableCommands.push({
+        name: "compact",
+        description: "compact the session",
+      })
 
-      setTimeout(() => {
-        void this.connection.sessionUpdate({
-          sessionId,
-          update: {
-            sessionUpdate: "available_commands_update",
-            availableCommands,
-          },
-        })
-      }, 0)
+    const mcpServers: Record<string, ConfigMCP.Info> = {}
+    for (const server of params.mcpServers) {
+      if ("type" in server) {
+        mcpServers[server.name] = {
+          url: server.url,
+          headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
+            acc[name] = value
+            return acc
+          }, {}),
+          type: "remote",
+        }
+      } else {
+        mcpServers[server.name] = {
+          type: "local",
+          command: [server.command, ...server.args],
+          environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
+            acc[name] = value
+            return acc
+          }, {}),
+        }
+      }
+    }
 
-      return {
+    await Promise.all(
+      Object.entries(mcpServers).map(async ([key, mcp]) => {
+        await this.sdk.mcp
+          .add(
+            {
+              directory,
+              name: key,
+              config: mcp,
+            },
+            { throwOnError: true },
+          )
+          .catch((error) => {
+            log.error("failed to add mcp server", { name: key, error })
+          })
+      }),
+    )
+
+    setTimeout(() => {
+      void this.connection.sessionUpdate({
         sessionId,
-        models: {
-          currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
-          availableModels,
+        update: {
+          sessionUpdate: "available_commands_update",
+          availableCommands,
         },
+      })
+    }, 0)
+
+    return {
+      sessionId,
+      models: {
+        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
+        availableModels,
+      },
+      modes,
+      configOptions: buildConfigOptions({
+        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
+        availableModels,
         modes,
-        configOptions: buildConfigOptions({
-          currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
-          availableModels,
-          modes,
-        }),
-        _meta: buildVariantMeta({
-          model,
-          variant: this.sessionManager.getVariant(sessionId),
-          availableVariants,
-        }),
-      }
+      }),
+      _meta: buildVariantMeta({
+        model,
+        variant: this.sessionManager.getVariant(sessionId),
+        availableVariants,
+      }),
     }
+  }
 
-    async unstable_setSessionModel(params: SetSessionModelRequest) {
-      const session = this.sessionManager.get(params.sessionId)
-      const providers = await this.sdk.config
-        .providers({ directory: session.cwd }, { throwOnError: true })
-        .then((x) => x.data!.providers)
+  async unstable_setSessionModel(params: SetSessionModelRequest) {
+    const session = this.sessionManager.get(params.sessionId)
+    const providers = await this.sdk.config
+      .providers({ directory: session.cwd }, { throwOnError: true })
+      .then((x) => x.data!.providers)
 
-      const selection = parseModelSelection(params.modelId, providers)
-      this.sessionManager.setModel(session.id, selection.model)
-      this.sessionManager.setVariant(session.id, selection.variant)
+    const selection = parseModelSelection(params.modelId, providers)
+    this.sessionManager.setModel(session.id, selection.model)
+    this.sessionManager.setVariant(session.id, selection.variant)
 
-      const entries = sortProvidersByName(providers)
-      const availableVariants = modelVariantsFromProviders(entries, selection.model)
+    const entries = sortProvidersByName(providers)
+    const availableVariants = modelVariantsFromProviders(entries, selection.model)
 
-      return {
-        _meta: buildVariantMeta({
-          model: selection.model,
-          variant: selection.variant,
-          availableVariants,
-        }),
-      }
+    return {
+      _meta: buildVariantMeta({
+        model: selection.model,
+        variant: selection.variant,
+        availableVariants,
+      }),
     }
+  }
 
-    async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
-      const session = this.sessionManager.get(params.sessionId)
-      const availableModes = await this.loadAvailableModes(session.cwd)
-      if (!availableModes.some((mode) => mode.id === params.modeId)) {
-        throw new Error(`Agent not found: ${params.modeId}`)
-      }
-      this.sessionManager.setMode(params.sessionId, params.modeId)
+  async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
+    const session = this.sessionManager.get(params.sessionId)
+    const availableModes = await this.loadAvailableModes(session.cwd)
+    if (!availableModes.some((mode) => mode.id === params.modeId)) {
+      throw new Error(`Agent not found: ${params.modeId}`)
     }
+    this.sessionManager.setMode(params.sessionId, params.modeId)
+  }
 
-    async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
-      const session = this.sessionManager.get(params.sessionId)
-      const providers = await this.sdk.config
-        .providers({ directory: session.cwd }, { throwOnError: true })
-        .then((x) => x.data!.providers)
-      const entries = sortProvidersByName(providers)
-
-      if (params.configId === "model") {
-        if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
-        const selection = parseModelSelection(params.value, providers)
-        this.sessionManager.setModel(session.id, selection.model)
-        this.sessionManager.setVariant(session.id, selection.variant)
-      } else if (params.configId === "mode") {
-        if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
-        const availableModes = await this.loadAvailableModes(session.cwd)
-        if (!availableModes.some((mode) => mode.id === params.value)) {
-          throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
-        }
-        this.sessionManager.setMode(session.id, params.value)
-      } else {
-        throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
+  async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
+    const session = this.sessionManager.get(params.sessionId)
+    const providers = await this.sdk.config
+      .providers({ directory: session.cwd }, { throwOnError: true })
+      .then((x) => x.data!.providers)
+    const entries = sortProvidersByName(providers)
+
+    if (params.configId === "model") {
+      if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
+      const selection = parseModelSelection(params.value, providers)
+      this.sessionManager.setModel(session.id, selection.model)
+      this.sessionManager.setVariant(session.id, selection.variant)
+    } else if (params.configId === "mode") {
+      if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
+      const availableModes = await this.loadAvailableModes(session.cwd)
+      if (!availableModes.some((mode) => mode.id === params.value)) {
+        throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
       }
+      this.sessionManager.setMode(session.id, params.value)
+    } else {
+      throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
+    }
 
-      const updatedSession = this.sessionManager.get(session.id)
-      const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
-      const availableVariants = modelVariantsFromProviders(entries, model)
-      const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
-      const availableModels = buildAvailableModels(entries, { includeVariants: true })
-      const modeState = await this.resolveModeState(session.cwd, session.id)
-      const modes = modeState.currentModeId
-        ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
-        : undefined
+    const updatedSession = this.sessionManager.get(session.id)
+    const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
+    const availableVariants = modelVariantsFromProviders(entries, model)
+    const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
+    const availableModels = buildAvailableModels(entries, { includeVariants: true })
+    const modeState = await this.resolveModeState(session.cwd, session.id)
+    const modes = modeState.currentModeId
+      ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
+      : undefined
 
-      return {
-        configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
-      }
+    return {
+      configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
     }
+  }
 
-    async prompt(params: PromptRequest) {
-      const sessionID = params.sessionId
-      const session = this.sessionManager.get(sessionID)
-      const directory = session.cwd
+  async prompt(params: PromptRequest) {
+    const sessionID = params.sessionId
+    const session = this.sessionManager.get(sessionID)
+    const directory = session.cwd
 
-      const current = session.model
-      const model = current ?? (await defaultModel(this.config, directory))
-      if (!current) {
-        this.sessionManager.setModel(session.id, model)
-      }
-      const agent =
-        session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
-
-      const parts: Array<
-        | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
-        | { type: "file"; url: string; filename: string; mime: string }
-      > = []
-      for (const part of params.prompt) {
-        switch (part.type) {
-          case "text":
-            const audience = part.annotations?.audience
-            const forAssistant = audience?.length === 1 && audience[0] === "assistant"
-            const forUser = audience?.length === 1 && audience[0] === "user"
+    const current = session.model
+    const model = current ?? (await defaultModel(this.config, directory))
+    if (!current) {
+      this.sessionManager.setModel(session.id, model)
+    }
+    const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
+
+    const parts: Array<
+      | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
+      | { type: "file"; url: string; filename: string; mime: string }
+    > = []
+    for (const part of params.prompt) {
+      switch (part.type) {
+        case "text":
+          const audience = part.annotations?.audience
+          const forAssistant = audience?.length === 1 && audience[0] === "assistant"
+          const forUser = audience?.length === 1 && audience[0] === "user"
+          parts.push({
+            type: "text" as const,
+            text: part.text,
+            ...(forAssistant && { synthetic: true }),
+            ...(forUser && { ignored: true }),
+          })
+          break
+        case "image": {
+          const parsed = parseUri(part.uri ?? "")
+          const filename = parsed.type === "file" ? parsed.filename : "image"
+          if (part.data) {
             parts.push({
-              type: "text" as const,
-              text: part.text,
-              ...(forAssistant && { synthetic: true }),
-              ...(forUser && { ignored: true }),
+              type: "file",
+              url: `data:${part.mimeType};base64,${part.data}`,
+              filename,
+              mime: part.mimeType,
+            })
+          } else if (part.uri && part.uri.startsWith("http:")) {
+            parts.push({
+              type: "file",
+              url: part.uri,
+              filename,
+              mime: part.mimeType,
             })
-            break
-          case "image": {
-            const parsed = parseUri(part.uri ?? "")
-            const filename = parsed.type === "file" ? parsed.filename : "image"
-            if (part.data) {
-              parts.push({
-                type: "file",
-                url: `data:${part.mimeType};base64,${part.data}`,
-                filename,
-                mime: part.mimeType,
-              })
-            } else if (part.uri && part.uri.startsWith("http:")) {
-              parts.push({
-                type: "file",
-                url: part.uri,
-                filename,
-                mime: part.mimeType,
-              })
-            }
-            break
           }
+          break
+        }
 
-          case "resource_link":
-            const parsed = parseUri(part.uri)
-            // Use the name from resource_link if available
-            if (part.name && parsed.type === "file") {
-              parsed.filename = part.name
-            }
-            parts.push(parsed)
+        case "resource_link":
+          const parsed = parseUri(part.uri)
+          // Use the name from resource_link if available
+          if (part.name && parsed.type === "file") {
+            parsed.filename = part.name
+          }
+          parts.push(parsed)
 
-            break
+          break
 
-          case "resource": {
-            const resource = part.resource
-            if ("text" in resource && resource.text) {
-              parts.push({
-                type: "text",
-                text: resource.text,
-              })
-            } else if ("blob" in resource && resource.blob && resource.mimeType) {
-              // Binary resource (PDFs, etc.): store as file part with data URL
-              const parsed = parseUri(resource.uri ?? "")
-              const filename = parsed.type === "file" ? parsed.filename : "file"
-              parts.push({
-                type: "file",
-                url: `data:${resource.mimeType};base64,${resource.blob}`,
-                filename,
-                mime: resource.mimeType,
-              })
-            }
-            break
+        case "resource": {
+          const resource = part.resource
+          if ("text" in resource && resource.text) {
+            parts.push({
+              type: "text",
+              text: resource.text,
+            })
+          } else if ("blob" in resource && resource.blob && resource.mimeType) {
+            // Binary resource (PDFs, etc.): store as file part with data URL
+            const parsed = parseUri(resource.uri ?? "")
+            const filename = parsed.type === "file" ? parsed.filename : "file"
+            parts.push({
+              type: "file",
+              url: `data:${resource.mimeType};base64,${resource.blob}`,
+              filename,
+              mime: resource.mimeType,
+            })
           }
-
-          default:
-            break
+          break
         }
-      }
-
-      log.info("parts", { parts })
-
-      const cmd = (() => {
-        const text = parts
-          .filter((p): p is { type: "text"; text: string } => p.type === "text")
-          .map((p) => p.text)
-          .join("")
-          .trim()
-
-        if (!text.startsWith("/")) return
-
-        const [name, ...rest] = text.slice(1).split(/\s+/)
-        return { name, args: rest.join(" ").trim() }
-      })()
-
-      const buildUsage = (msg: AssistantMessage): Usage => ({
-        totalTokens:
-          msg.tokens.input +
-          msg.tokens.output +
-          msg.tokens.reasoning +
-          (msg.tokens.cache?.read ?? 0) +
-          (msg.tokens.cache?.write ?? 0),
-        inputTokens: msg.tokens.input,
-        outputTokens: msg.tokens.output,
-        thoughtTokens: msg.tokens.reasoning || undefined,
-        cachedReadTokens: msg.tokens.cache?.read || undefined,
-        cachedWriteTokens: msg.tokens.cache?.write || undefined,
-      })
 
-      if (!cmd) {
-        const response = await this.sdk.session.prompt({
-          sessionID,
-          model: {
-            providerID: model.providerID,
-            modelID: model.modelID,
-          },
-          variant: this.sessionManager.getVariant(sessionID),
-          parts,
-          agent,
-          directory,
-        })
-        const msg = response.data?.info
-
-        await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
-
-        return {
-          stopReason: "end_turn" as const,
-          usage: msg ? buildUsage(msg) : undefined,
-          _meta: {},
-        }
+        default:
+          break
       }
+    }
 
-      const command = await this.config.sdk.command
-        .list({ directory }, { throwOnError: true })
-        .then((x) => x.data!.find((c) => c.name === cmd.name))
-      if (command) {
-        const response = await this.sdk.session.command({
-          sessionID,
-          command: command.name,
-          arguments: cmd.args,
-          model: model.providerID + "/" + model.modelID,
-          agent,
-          directory,
-        })
-        const msg = response.data?.info
+    log.info("parts", { parts })
+
+    const cmd = (() => {
+      const text = parts
+        .filter((p): p is { type: "text"; text: string } => p.type === "text")
+        .map((p) => p.text)
+        .join("")
+        .trim()
+
+      if (!text.startsWith("/")) return
+
+      const [name, ...rest] = text.slice(1).split(/\s+/)
+      return { name, args: rest.join(" ").trim() }
+    })()
+
+    const buildUsage = (msg: AssistantMessage): Usage => ({
+      totalTokens:
+        msg.tokens.input +
+        msg.tokens.output +
+        msg.tokens.reasoning +
+        (msg.tokens.cache?.read ?? 0) +
+        (msg.tokens.cache?.write ?? 0),
+      inputTokens: msg.tokens.input,
+      outputTokens: msg.tokens.output,
+      thoughtTokens: msg.tokens.reasoning || undefined,
+      cachedReadTokens: msg.tokens.cache?.read || undefined,
+      cachedWriteTokens: msg.tokens.cache?.write || undefined,
+    })
 
-        await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
+    if (!cmd) {
+      const response = await this.sdk.session.prompt({
+        sessionID,
+        model: {
+          providerID: model.providerID,
+          modelID: model.modelID,
+        },
+        variant: this.sessionManager.getVariant(sessionID),
+        parts,
+        agent,
+        directory,
+      })
+      const msg = response.data?.info
 
-        return {
-          stopReason: "end_turn" as const,
-          usage: msg ? buildUsage(msg) : undefined,
-          _meta: {},
-        }
-      }
+      await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
 
-      switch (cmd.name) {
-        case "compact":
-          await this.config.sdk.session.summarize(
-            {
-              sessionID,
-              directory,
-              providerID: model.providerID,
-              modelID: model.modelID,
-            },
-            { throwOnError: true },
-          )
-          break
+      return {
+        stopReason: "end_turn" as const,
+        usage: msg ? buildUsage(msg) : undefined,
+        _meta: {},
       }
+    }
+
+    const command = await this.config.sdk.command
+      .list({ directory }, { throwOnError: true })
+      .then((x) => x.data!.find((c) => c.name === cmd.name))
+    if (command) {
+      const response = await this.sdk.session.command({
+        sessionID,
+        command: command.name,
+        arguments: cmd.args,
+        model: model.providerID + "/" + model.modelID,
+        agent,
+        directory,
+      })
+      const msg = response.data?.info
 
       await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
 
       return {
         stopReason: "end_turn" as const,
+        usage: msg ? buildUsage(msg) : undefined,
         _meta: {},
       }
     }
 
-    async cancel(params: CancelNotification) {
-      const session = this.sessionManager.get(params.sessionId)
-      await this.config.sdk.session.abort(
-        {
-          sessionID: params.sessionId,
-          directory: session.cwd,
-        },
-        { throwOnError: true },
-      )
+    switch (cmd.name) {
+      case "compact":
+        await this.config.sdk.session.summarize(
+          {
+            sessionID,
+            directory,
+            providerID: model.providerID,
+            modelID: model.modelID,
+          },
+          { throwOnError: true },
+        )
+        break
     }
-  }
 
-  function toToolKind(toolName: string): ToolKind {
-    const tool = toolName.toLocaleLowerCase()
-    switch (tool) {
-      case "bash":
-        return "execute"
-      case "webfetch":
-        return "fetch"
-
-      case "edit":
-      case "patch":
-      case "write":
-        return "edit"
-
-      case "grep":
-      case "glob":
-      case "context7_resolve_library_id":
-      case "context7_get_library_docs":
-        return "search"
-
-      case "read":
-        return "read"
-
-      default:
-        return "other"
+    await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
+
+    return {
+      stopReason: "end_turn" as const,
+      _meta: {},
     }
   }
 
-  function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
-    const tool = toolName.toLocaleLowerCase()
-    switch (tool) {
-      case "read":
-      case "edit":
-      case "write":
-        return input["filePath"] ? [{ path: input["filePath"] }] : []
-      case "glob":
-      case "grep":
-        return input["path"] ? [{ path: input["path"] }] : []
-      case "bash":
-        return []
-      default:
-        return []
-    }
+  async cancel(params: CancelNotification) {
+    const session = this.sessionManager.get(params.sessionId)
+    await this.config.sdk.session.abort(
+      {
+        sessionID: params.sessionId,
+        directory: session.cwd,
+      },
+      { throwOnError: true },
+    )
   }
+}
 
-  async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
-    const sdk = config.sdk
-    const configured = config.defaultModel
-    if (configured) return configured
+function toToolKind(toolName: string): ToolKind {
+  const tool = toolName.toLocaleLowerCase()
+  switch (tool) {
+    case "bash":
+      return "execute"
+    case "webfetch":
+      return "fetch"
+
+    case "edit":
+    case "patch":
+    case "write":
+      return "edit"
+
+    case "grep":
+    case "glob":
+    case "context7_resolve_library_id":
+    case "context7_get_library_docs":
+      return "search"
+
+    case "read":
+      return "read"
+
+    default:
+      return "other"
+  }
+}
 
-    const directory = cwd ?? process.cwd()
+function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
+  const tool = toolName.toLocaleLowerCase()
+  switch (tool) {
+    case "read":
+    case "edit":
+    case "write":
+      return input["filePath"] ? [{ path: input["filePath"] }] : []
+    case "glob":
+    case "grep":
+      return input["path"] ? [{ path: input["path"] }] : []
+    case "bash":
+      return []
+    default:
+      return []
+  }
+}
 
-    const specified = await sdk.config
-      .get({ directory }, { throwOnError: true })
-      .then((resp) => {
-        const cfg = resp.data
-        if (!cfg || !cfg.model) return undefined
-        return Provider.parseModel(cfg.model)
-      })
-      .catch((error) => {
-        log.error("failed to load user config for default model", { error })
-        return undefined
-      })
+async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
+  const sdk = config.sdk
+  const configured = config.defaultModel
+  if (configured) return configured
 
-    const providers = await sdk.config
-      .providers({ directory }, { throwOnError: true })
-      .then((x) => x.data?.providers ?? [])
-      .catch((error) => {
-        log.error("failed to list providers for default model", { error })
-        return []
-      })
+  const directory = cwd ?? process.cwd()
 
-    if (specified && providers.length) {
-      const provider = providers.find((p) => p.id === specified.providerID)
-      if (provider && provider.models[specified.modelID]) return specified
-    }
+  const specified = await sdk.config
+    .get({ directory }, { throwOnError: true })
+    .then((resp) => {
+      const cfg = resp.data
+      if (!cfg || !cfg.model) return undefined
+      return Provider.parseModel(cfg.model)
+    })
+    .catch((error) => {
+      log.error("failed to load user config for default model", { error })
+      return undefined
+    })
 
-    if (specified && !providers.length) return specified
+  const providers = await sdk.config
+    .providers({ directory }, { throwOnError: true })
+    .then((x) => x.data?.providers ?? [])
+    .catch((error) => {
+      log.error("failed to list providers for default model", { error })
+      return []
+    })
 
-    const opencodeProvider = providers.find((p) => p.id === "opencode")
-    if (opencodeProvider) {
-      if (opencodeProvider.models["big-pickle"]) {
-        return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
-      }
-      const [best] = Provider.sort(Object.values(opencodeProvider.models))
-      if (best) {
-        return {
-          providerID: ProviderID.make(best.providerID),
-          modelID: ModelID.make(best.id),
-        }
-      }
-    }
+  if (specified && providers.length) {
+    const provider = providers.find((p) => p.id === specified.providerID)
+    if (provider && provider.models[specified.modelID]) return specified
+  }
+
+  if (specified && !providers.length) return specified
 
-    const models = providers.flatMap((p) => Object.values(p.models))
-    const [best] = Provider.sort(models)
+  const opencodeProvider = providers.find((p) => p.id === "opencode")
+  if (opencodeProvider) {
+    if (opencodeProvider.models["big-pickle"]) {
+      return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
+    }
+    const [best] = Provider.sort(Object.values(opencodeProvider.models))
     if (best) {
       return {
         providerID: ProviderID.make(best.providerID),
         modelID: ModelID.make(best.id),
       }
     }
+  }
 
-    if (specified) return specified
-
-    return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
+  const models = providers.flatMap((p) => Object.values(p.models))
+  const [best] = Provider.sort(models)
+  if (best) {
+    return {
+      providerID: ProviderID.make(best.providerID),
+      modelID: ModelID.make(best.id),
+    }
   }
 
-  function parseUri(
-    uri: string,
-  ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
-    try {
-      if (uri.startsWith("file://")) {
-        const path = uri.slice(7)
+  if (specified) return specified
+
+  return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
+}
+
+function parseUri(
+  uri: string,
+): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
+  try {
+    if (uri.startsWith("file://")) {
+      const path = uri.slice(7)
+      const name = path.split("/").pop() || path
+      return {
+        type: "file",
+        url: uri,
+        filename: name,
+        mime: "text/plain",
+      }
+    }
+    if (uri.startsWith("zed://")) {
+      const url = new URL(uri)
+      const path = url.searchParams.get("path")
+      if (path) {
         const name = path.split("/").pop() || path
         return {
           type: "file",
-          url: uri,
+          url: pathToFileURL(path).href,
           filename: name,
           mime: "text/plain",
         }
       }
-      if (uri.startsWith("zed://")) {
-        const url = new URL(uri)
-        const path = url.searchParams.get("path")
-        if (path) {
-          const name = path.split("/").pop() || path
-          return {
-            type: "file",
-            url: pathToFileURL(path).href,
-            filename: name,
-            mime: "text/plain",
-          }
-        }
-      }
-      return {
-        type: "text",
-        text: uri,
-      }
-    } catch {
-      return {
-        type: "text",
-        text: uri,
-      }
     }
-  }
-
-  function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
-    const result = applyPatch(fileOriginal, unifiedDiff)
-    if (result === false) {
-      log.error("Failed to apply unified diff (context mismatch)")
-      return undefined
+    return {
+      type: "text",
+      text: uri,
+    }
+  } catch {
+    return {
+      type: "text",
+      text: uri,
     }
-    return result
   }
+}
 
-  function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
-    return [...providers].sort((a, b) => {
-      const nameA = a.name.toLowerCase()
-      const nameB = b.name.toLowerCase()
-      if (nameA < nameB) return -1
-      if (nameA > nameB) return 1
-      return 0
-    })
+function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
+  const result = applyPatch(fileOriginal, unifiedDiff)
+  if (result === false) {
+    log.error("Failed to apply unified diff (context mismatch)")
+    return undefined
   }
+  return result
+}
 
-  function modelVariantsFromProviders(
-    providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
-    model: { providerID: ProviderID; modelID: ModelID },
-  ): string[] {
-    const provider = providers.find((entry) => entry.id === model.providerID)
-    if (!provider) return []
-    const modelInfo = provider.models[model.modelID]
-    if (!modelInfo?.variants) return []
-    return Object.keys(modelInfo.variants)
-  }
+function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
+  return [...providers].sort((a, b) => {
+    const nameA = a.name.toLowerCase()
+    const nameB = b.name.toLowerCase()
+    if (nameA < nameB) return -1
+    if (nameA > nameB) return 1
+    return 0
+  })
+}
 
-  function buildAvailableModels(
-    providers: Array<{ id: string; name: string; models: Record<string, any> }>,
-    options: { includeVariants?: boolean } = {},
-  ): ModelOption[] {
-    const includeVariants = options.includeVariants ?? false
-    return providers.flatMap((provider) => {
-      const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(
-        provider.models,
-      )
-      const models = Provider.sort(unsorted)
-      return models.flatMap((model) => {
-        const base: ModelOption = {
-          modelId: `${provider.id}/${model.id}`,
-          name: `${provider.name}/${model.name}`,
-        }
-        if (!includeVariants || !model.variants) return [base]
-        const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
-        const variantOptions = variants.map((variant) => ({
-          modelId: `${provider.id}/${model.id}/${variant}`,
-          name: `${provider.name}/${model.name} (${variant})`,
-        }))
-        return [base, ...variantOptions]
-      })
+function modelVariantsFromProviders(
+  providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
+  model: { providerID: ProviderID; modelID: ModelID },
+): string[] {
+  const provider = providers.find((entry) => entry.id === model.providerID)
+  if (!provider) return []
+  const modelInfo = provider.models[model.modelID]
+  if (!modelInfo?.variants) return []
+  return Object.keys(modelInfo.variants)
+}
+
+function buildAvailableModels(
+  providers: Array<{ id: string; name: string; models: Record<string, any> }>,
+  options: { includeVariants?: boolean } = {},
+): ModelOption[] {
+  const includeVariants = options.includeVariants ?? false
+  return providers.flatMap((provider) => {
+    const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(provider.models)
+    const models = Provider.sort(unsorted)
+    return models.flatMap((model) => {
+      const base: ModelOption = {
+        modelId: `${provider.id}/${model.id}`,
+        name: `${provider.name}/${model.name}`,
+      }
+      if (!includeVariants || !model.variants) return [base]
+      const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
+      const variantOptions = variants.map((variant) => ({
+        modelId: `${provider.id}/${model.id}/${variant}`,
+        name: `${provider.name}/${model.name} (${variant})`,
+      }))
+      return [base, ...variantOptions]
     })
-  }
+  })
+}
 
-  function formatModelIdWithVariant(
-    model: { providerID: ProviderID; modelID: ModelID },
-    variant: string | undefined,
-    availableVariants: string[],
-    includeVariant: boolean,
-  ) {
-    const base = `${model.providerID}/${model.modelID}`
-    if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
-    return `${base}/${variant}`
-  }
+function formatModelIdWithVariant(
+  model: { providerID: ProviderID; modelID: ModelID },
+  variant: string | undefined,
+  availableVariants: string[],
+  includeVariant: boolean,
+) {
+  const base = `${model.providerID}/${model.modelID}`
+  if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
+  return `${base}/${variant}`
+}
 
-  function buildVariantMeta(input: {
-    model: { providerID: ProviderID; modelID: ModelID }
-    variant?: string
-    availableVariants: string[]
-  }) {
-    return {
-      opencode: {
-        modelId: `${input.model.providerID}/${input.model.modelID}`,
-        variant: input.variant ?? null,
-        availableVariants: input.availableVariants,
-      },
-    }
+function buildVariantMeta(input: {
+  model: { providerID: ProviderID; modelID: ModelID }
+  variant?: string
+  availableVariants: string[]
+}) {
+  return {
+    opencode: {
+      modelId: `${input.model.providerID}/${input.model.modelID}`,
+      variant: input.variant ?? null,
+      availableVariants: input.availableVariants,
+    },
   }
+}
 
-  function parseModelSelection(
-    modelId: string,
-    providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
-  ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
-    const parsed = Provider.parseModel(modelId)
-    const provider = providers.find((p) => p.id === parsed.providerID)
-    if (!provider) {
-      return { model: parsed, variant: undefined }
-    }
+function parseModelSelection(
+  modelId: string,
+  providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
+): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
+  const parsed = Provider.parseModel(modelId)
+  const provider = providers.find((p) => p.id === parsed.providerID)
+  if (!provider) {
+    return { model: parsed, variant: undefined }
+  }
 
-    // Check if modelID exists directly
-    if (provider.models[parsed.modelID]) {
-      return { model: parsed, variant: undefined }
-    }
+  // Check if modelID exists directly
+  if (provider.models[parsed.modelID]) {
+    return { model: parsed, variant: undefined }
+  }
 
-    // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high")
-    const segments = parsed.modelID.split("/")
-    if (segments.length > 1) {
-      const candidateVariant = segments[segments.length - 1]
-      const baseModelId = segments.slice(0, -1).join("/")
-      const baseModelInfo = provider.models[baseModelId]
-      if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
-        return {
-          model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
-          variant: candidateVariant,
-        }
+  // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high")
+  const segments = parsed.modelID.split("/")
+  if (segments.length > 1) {
+    const candidateVariant = segments[segments.length - 1]
+    const baseModelId = segments.slice(0, -1).join("/")
+    const baseModelInfo = provider.models[baseModelId]
+    if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
+      return {
+        model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
+        variant: candidateVariant,
       }
     }
-
-    return { model: parsed, variant: undefined }
   }
 
-  function buildConfigOptions(input: {
-    currentModelId: string
-    availableModels: ModelOption[]
-    modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
-  }): SessionConfigOption[] {
-    const options: SessionConfigOption[] = [
-      {
-        id: "model",
-        name: "Model",
-        category: "model",
-        type: "select",
-        currentValue: input.currentModelId,
-        options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
-      },
-    ]
-    if (input.modes) {
-      options.push({
-        id: "mode",
-        name: "Session Mode",
-        category: "mode",
-        type: "select",
-        currentValue: input.modes.currentModeId,
-        options: input.modes.availableModes.map((m) => ({
-          value: m.id,
-          name: m.name,
-          ...(m.description ? { description: m.description } : {}),
-        })),
-      })
-    }
-    return options
+  return { model: parsed, variant: undefined }
+}
+
+function buildConfigOptions(input: {
+  currentModelId: string
+  availableModels: ModelOption[]
+  modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
+}): SessionConfigOption[] {
+  const options: SessionConfigOption[] = [
+    {
+      id: "model",
+      name: "Model",
+      category: "model",
+      type: "select",
+      currentValue: input.currentModelId,
+      options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
+    },
+  ]
+  if (input.modes) {
+    options.push({
+      id: "mode",
+      name: "Session Mode",
+      category: "mode",
+      type: "select",
+      currentValue: input.modes.currentModeId,
+      options: input.modes.availableModes.map((m) => ({
+        value: m.id,
+        name: m.name,
+        ...(m.description ? { description: m.description } : {}),
+      })),
+    })
   }
+  return options
 }
+
+export * as ACP from "./agent"

+ 358 - 359
packages/opencode/src/agent/agent.ts

@@ -24,389 +24,388 @@ import { InstanceState } from "@/effect"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
 
-export namespace Agent {
-  export const Info = z
-    .object({
-      name: z.string(),
-      description: z.string().optional(),
-      mode: z.enum(["subagent", "primary", "all"]),
-      native: z.boolean().optional(),
-      hidden: z.boolean().optional(),
-      topP: z.number().optional(),
-      temperature: z.number().optional(),
-      color: z.string().optional(),
-      permission: Permission.Ruleset.zod,
-      model: z
-        .object({
-          modelID: ModelID.zod,
-          providerID: ProviderID.zod,
-        })
-        .optional(),
-      variant: z.string().optional(),
-      prompt: z.string().optional(),
-      options: z.record(z.string(), z.any()),
-      steps: z.number().int().positive().optional(),
-    })
-    .meta({
-      ref: "Agent",
-    })
-  export type Info = z.infer<typeof Info>
+export const Info = z
+  .object({
+    name: z.string(),
+    description: z.string().optional(),
+    mode: z.enum(["subagent", "primary", "all"]),
+    native: z.boolean().optional(),
+    hidden: z.boolean().optional(),
+    topP: z.number().optional(),
+    temperature: z.number().optional(),
+    color: z.string().optional(),
+    permission: Permission.Ruleset.zod,
+    model: z
+      .object({
+        modelID: ModelID.zod,
+        providerID: ProviderID.zod,
+      })
+      .optional(),
+    variant: z.string().optional(),
+    prompt: z.string().optional(),
+    options: z.record(z.string(), z.any()),
+    steps: z.number().int().positive().optional(),
+  })
+  .meta({
+    ref: "Agent",
+  })
+export type Info = z.infer<typeof Info>
 
-  export interface Interface {
-    readonly get: (agent: string) => Effect.Effect<Agent.Info>
-    readonly list: () => Effect.Effect<Agent.Info[]>
-    readonly defaultAgent: () => Effect.Effect<string>
-    readonly generate: (input: {
-      description: string
-      model?: { providerID: ProviderID; modelID: ModelID }
-    }) => Effect.Effect<{
-      identifier: string
-      whenToUse: string
-      systemPrompt: string
-    }>
-  }
+export interface Interface {
+  readonly get: (agent: string) => Effect.Effect<Info>
+  readonly list: () => Effect.Effect<Info[]>
+  readonly defaultAgent: () => Effect.Effect<string>
+  readonly generate: (input: {
+    description: string
+    model?: { providerID: ProviderID; modelID: ModelID }
+  }) => Effect.Effect<{
+    identifier: string
+    whenToUse: string
+    systemPrompt: string
+  }>
+}
 
-  type State = Omit<Interface, "generate">
+type State = Omit<Interface, "generate">
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const config = yield* Config.Service
-      const auth = yield* Auth.Service
-      const plugin = yield* Plugin.Service
-      const skill = yield* Skill.Service
-      const provider = yield* Provider.Service
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const config = yield* Config.Service
+    const auth = yield* Auth.Service
+    const plugin = yield* Plugin.Service
+    const skill = yield* Skill.Service
+    const provider = yield* Provider.Service
 
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Agent.state")(function* (_ctx) {
-          const cfg = yield* config.get()
-          const skillDirs = yield* skill.dirs()
-          const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Agent.state")(function* (_ctx) {
+        const cfg = yield* config.get()
+        const skillDirs = yield* skill.dirs()
+        const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
 
-          const defaults = Permission.fromConfig({
+        const defaults = Permission.fromConfig({
+          "*": "allow",
+          doom_loop: "ask",
+          external_directory: {
+            "*": "ask",
+            ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
+          },
+          question: "deny",
+          plan_enter: "deny",
+          plan_exit: "deny",
+          // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
+          read: {
             "*": "allow",
-            doom_loop: "ask",
-            external_directory: {
-              "*": "ask",
-              ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
-            },
-            question: "deny",
-            plan_enter: "deny",
-            plan_exit: "deny",
-            // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
-            read: {
-              "*": "allow",
-              "*.env": "ask",
-              "*.env.*": "ask",
-              "*.env.example": "allow",
-            },
-          })
+            "*.env": "ask",
+            "*.env.*": "ask",
+            "*.env.example": "allow",
+          },
+        })
 
-          const user = Permission.fromConfig(cfg.permission ?? {})
+        const user = Permission.fromConfig(cfg.permission ?? {})
 
-          const agents: Record<string, Info> = {
-            build: {
-              name: "build",
-              description: "The default agent. Executes tools based on configured permissions.",
-              options: {},
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  question: "allow",
-                  plan_enter: "allow",
-                }),
-                user,
-              ),
-              mode: "primary",
-              native: true,
-            },
-            plan: {
-              name: "plan",
-              description: "Plan mode. Disallows all edit tools.",
-              options: {},
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  question: "allow",
-                  plan_exit: "allow",
-                  external_directory: {
-                    [path.join(Global.Path.data, "plans", "*")]: "allow",
-                  },
-                  edit: {
-                    "*": "deny",
-                    [path.join(".opencode", "plans", "*.md")]: "allow",
-                    [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
-                      "allow",
-                  },
-                }),
-                user,
-              ),
-              mode: "primary",
-              native: true,
-            },
-            general: {
-              name: "general",
-              description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  todowrite: "deny",
-                }),
-                user,
-              ),
-              options: {},
-              mode: "subagent",
-              native: true,
-            },
-            explore: {
-              name: "explore",
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  "*": "deny",
-                  grep: "allow",
-                  glob: "allow",
-                  list: "allow",
-                  bash: "allow",
-                  webfetch: "allow",
-                  websearch: "allow",
-                  codesearch: "allow",
-                  read: "allow",
-                  external_directory: {
-                    "*": "ask",
-                    ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
-                  },
-                }),
-                user,
-              ),
-              description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
-              prompt: PROMPT_EXPLORE,
-              options: {},
-              mode: "subagent",
-              native: true,
-            },
-            compaction: {
-              name: "compaction",
-              mode: "primary",
-              native: true,
-              hidden: true,
-              prompt: PROMPT_COMPACTION,
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  "*": "deny",
-                }),
-                user,
-              ),
-              options: {},
-            },
-            title: {
-              name: "title",
-              mode: "primary",
-              options: {},
-              native: true,
-              hidden: true,
-              temperature: 0.5,
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
-                  "*": "deny",
-                }),
-                user,
-              ),
-              prompt: PROMPT_TITLE,
-            },
-            summary: {
-              name: "summary",
-              mode: "primary",
-              options: {},
-              native: true,
-              hidden: true,
-              permission: Permission.merge(
-                defaults,
-                Permission.fromConfig({
+        const agents: Record<string, Info> = {
+          build: {
+            name: "build",
+            description: "The default agent. Executes tools based on configured permissions.",
+            options: {},
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                question: "allow",
+                plan_enter: "allow",
+              }),
+              user,
+            ),
+            mode: "primary",
+            native: true,
+          },
+          plan: {
+            name: "plan",
+            description: "Plan mode. Disallows all edit tools.",
+            options: {},
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                question: "allow",
+                plan_exit: "allow",
+                external_directory: {
+                  [path.join(Global.Path.data, "plans", "*")]: "allow",
+                },
+                edit: {
                   "*": "deny",
-                }),
-                user,
-              ),
-              prompt: PROMPT_SUMMARY,
-            },
-          }
+                  [path.join(".opencode", "plans", "*.md")]: "allow",
+                  [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
+                },
+              }),
+              user,
+            ),
+            mode: "primary",
+            native: true,
+          },
+          general: {
+            name: "general",
+            description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                todowrite: "deny",
+              }),
+              user,
+            ),
+            options: {},
+            mode: "subagent",
+            native: true,
+          },
+          explore: {
+            name: "explore",
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                "*": "deny",
+                grep: "allow",
+                glob: "allow",
+                list: "allow",
+                bash: "allow",
+                webfetch: "allow",
+                websearch: "allow",
+                codesearch: "allow",
+                read: "allow",
+                external_directory: {
+                  "*": "ask",
+                  ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
+                },
+              }),
+              user,
+            ),
+            description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
+            prompt: PROMPT_EXPLORE,
+            options: {},
+            mode: "subagent",
+            native: true,
+          },
+          compaction: {
+            name: "compaction",
+            mode: "primary",
+            native: true,
+            hidden: true,
+            prompt: PROMPT_COMPACTION,
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                "*": "deny",
+              }),
+              user,
+            ),
+            options: {},
+          },
+          title: {
+            name: "title",
+            mode: "primary",
+            options: {},
+            native: true,
+            hidden: true,
+            temperature: 0.5,
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                "*": "deny",
+              }),
+              user,
+            ),
+            prompt: PROMPT_TITLE,
+          },
+          summary: {
+            name: "summary",
+            mode: "primary",
+            options: {},
+            native: true,
+            hidden: true,
+            permission: Permission.merge(
+              defaults,
+              Permission.fromConfig({
+                "*": "deny",
+              }),
+              user,
+            ),
+            prompt: PROMPT_SUMMARY,
+          },
+        }
 
-          for (const [key, value] of Object.entries(cfg.agent ?? {})) {
-            if (value.disable) {
-              delete agents[key]
-              continue
-            }
-            let item = agents[key]
-            if (!item)
-              item = agents[key] = {
-                name: key,
-                mode: "all",
-                permission: Permission.merge(defaults, user),
-                options: {},
-                native: false,
-              }
-            if (value.model) item.model = Provider.parseModel(value.model)
-            item.variant = value.variant ?? item.variant
-            item.prompt = value.prompt ?? item.prompt
-            item.description = value.description ?? item.description
-            item.temperature = value.temperature ?? item.temperature
-            item.topP = value.top_p ?? item.topP
-            item.mode = value.mode ?? item.mode
-            item.color = value.color ?? item.color
-            item.hidden = value.hidden ?? item.hidden
-            item.name = value.name ?? item.name
-            item.steps = value.steps ?? item.steps
-            item.options = mergeDeep(item.options, value.options ?? {})
-            item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
+        for (const [key, value] of Object.entries(cfg.agent ?? {})) {
+          if (value.disable) {
+            delete agents[key]
+            continue
           }
+          let item = agents[key]
+          if (!item)
+            item = agents[key] = {
+              name: key,
+              mode: "all",
+              permission: Permission.merge(defaults, user),
+              options: {},
+              native: false,
+            }
+          if (value.model) item.model = Provider.parseModel(value.model)
+          item.variant = value.variant ?? item.variant
+          item.prompt = value.prompt ?? item.prompt
+          item.description = value.description ?? item.description
+          item.temperature = value.temperature ?? item.temperature
+          item.topP = value.top_p ?? item.topP
+          item.mode = value.mode ?? item.mode
+          item.color = value.color ?? item.color
+          item.hidden = value.hidden ?? item.hidden
+          item.name = value.name ?? item.name
+          item.steps = value.steps ?? item.steps
+          item.options = mergeDeep(item.options, value.options ?? {})
+          item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
+        }
 
-          // Ensure Truncate.GLOB is allowed unless explicitly configured
-          for (const name in agents) {
-            const agent = agents[name]
-            const explicit = agent.permission.some((r) => {
-              if (r.permission !== "external_directory") return false
-              if (r.action !== "deny") return false
-              return r.pattern === Truncate.GLOB
-            })
-            if (explicit) continue
+        // Ensure Truncate.GLOB is allowed unless explicitly configured
+        for (const name in agents) {
+          const agent = agents[name]
+          const explicit = agent.permission.some((r) => {
+            if (r.permission !== "external_directory") return false
+            if (r.action !== "deny") return false
+            return r.pattern === Truncate.GLOB
+          })
+          if (explicit) continue
 
-            agents[name].permission = Permission.merge(
-              agents[name].permission,
-              Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
-            )
-          }
+          agents[name].permission = Permission.merge(
+            agents[name].permission,
+            Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
+          )
+        }
 
-          const get = Effect.fnUntraced(function* (agent: string) {
-            return agents[agent]
-          })
+        const get = Effect.fnUntraced(function* (agent: string) {
+          return agents[agent]
+        })
 
-          const list = Effect.fnUntraced(function* () {
-            const cfg = yield* config.get()
-            return pipe(
-              agents,
-              values(),
-              sortBy(
-                [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
-                [(x) => x.name, "asc"],
-              ),
-            )
-          })
+        const list = Effect.fnUntraced(function* () {
+          const cfg = yield* config.get()
+          return pipe(
+            agents,
+            values(),
+            sortBy(
+              [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
+              [(x) => x.name, "asc"],
+            ),
+          )
+        })
 
-          const defaultAgent = Effect.fnUntraced(function* () {
-            const c = yield* config.get()
-            if (c.default_agent) {
-              const agent = agents[c.default_agent]
-              if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
-              if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
-              if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
-              return agent.name
-            }
-            const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
-            if (!visible) throw new Error("no primary visible agent found")
-            return visible.name
-          })
+        const defaultAgent = Effect.fnUntraced(function* () {
+          const c = yield* config.get()
+          if (c.default_agent) {
+            const agent = agents[c.default_agent]
+            if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
+            if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
+            if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
+            return agent.name
+          }
+          const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
+          if (!visible) throw new Error("no primary visible agent found")
+          return visible.name
+        })
 
-          return {
-            get,
-            list,
-            defaultAgent,
-          } satisfies State
-        }),
-      )
+        return {
+          get,
+          list,
+          defaultAgent,
+        } satisfies State
+      }),
+    )
 
-      return Service.of({
-        get: Effect.fn("Agent.get")(function* (agent: string) {
-          return yield* InstanceState.useEffect(state, (s) => s.get(agent))
-        }),
-        list: Effect.fn("Agent.list")(function* () {
-          return yield* InstanceState.useEffect(state, (s) => s.list())
-        }),
-        defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
-          return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
-        }),
-        generate: Effect.fn("Agent.generate")(function* (input: {
-          description: string
-          model?: { providerID: ProviderID; modelID: ModelID }
-        }) {
-          const cfg = yield* config.get()
-          const model = input.model ?? (yield* provider.defaultModel())
-          const resolved = yield* provider.getModel(model.providerID, model.modelID)
-          const language = yield* provider.getLanguage(resolved)
-          const tracer = cfg.experimental?.openTelemetry
-            ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
-            : undefined
+    return Service.of({
+      get: Effect.fn("Agent.get")(function* (agent: string) {
+        return yield* InstanceState.useEffect(state, (s) => s.get(agent))
+      }),
+      list: Effect.fn("Agent.list")(function* () {
+        return yield* InstanceState.useEffect(state, (s) => s.list())
+      }),
+      defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
+        return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
+      }),
+      generate: Effect.fn("Agent.generate")(function* (input: {
+        description: string
+        model?: { providerID: ProviderID; modelID: ModelID }
+      }) {
+        const cfg = yield* config.get()
+        const model = input.model ?? (yield* provider.defaultModel())
+        const resolved = yield* provider.getModel(model.providerID, model.modelID)
+        const language = yield* provider.getLanguage(resolved)
+        const tracer = cfg.experimental?.openTelemetry
+          ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
+          : undefined
 
-          const system = [PROMPT_GENERATE]
-          yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
-          const existing = yield* InstanceState.useEffect(state, (s) => s.list())
+        const system = [PROMPT_GENERATE]
+        yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
+        const existing = yield* InstanceState.useEffect(state, (s) => s.list())
 
-          // TODO: clean this up so provider specific logic doesnt bleed over
-          const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
-          const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
+        // TODO: clean this up so provider specific logic doesnt bleed over
+        const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
+        const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
 
-          const params = {
-            experimental_telemetry: {
-              isEnabled: cfg.experimental?.openTelemetry,
-              tracer,
-              metadata: {
-                userId: cfg.username ?? "unknown",
-              },
+        const params = {
+          experimental_telemetry: {
+            isEnabled: cfg.experimental?.openTelemetry,
+            tracer,
+            metadata: {
+              userId: cfg.username ?? "unknown",
+            },
+          },
+          temperature: 0.3,
+          messages: [
+            ...(isOpenaiOauth
+              ? []
+              : system.map(
+                  (item): ModelMessage => ({
+                    role: "system",
+                    content: item,
+                  }),
+                )),
+            {
+              role: "user",
+              content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n  Return ONLY the JSON object, no other text, do not wrap in backticks`,
             },
-            temperature: 0.3,
-            messages: [
-              ...(isOpenaiOauth
-                ? []
-                : system.map(
-                    (item): ModelMessage => ({
-                      role: "system",
-                      content: item,
-                    }),
-                  )),
-              {
-                role: "user",
-                content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n  Return ONLY the JSON object, no other text, do not wrap in backticks`,
-              },
-            ],
-            model: language,
-            schema: z.object({
-              identifier: z.string(),
-              whenToUse: z.string(),
-              systemPrompt: z.string(),
-            }),
-          } satisfies Parameters<typeof generateObject>[0]
+          ],
+          model: language,
+          schema: z.object({
+            identifier: z.string(),
+            whenToUse: z.string(),
+            systemPrompt: z.string(),
+          }),
+        } satisfies Parameters<typeof generateObject>[0]
 
-          if (isOpenaiOauth) {
-            return yield* Effect.promise(async () => {
-              const result = streamObject({
-                ...params,
-                providerOptions: ProviderTransform.providerOptions(resolved, {
-                  instructions: system.join("\n"),
-                  store: false,
-                }),
-                onError: () => {},
-              })
-              for await (const part of result.fullStream) {
-                if (part.type === "error") throw part.error
-              }
-              return result.object
+        if (isOpenaiOauth) {
+          return yield* Effect.promise(async () => {
+            const result = streamObject({
+              ...params,
+              providerOptions: ProviderTransform.providerOptions(resolved, {
+                instructions: system.join("\n"),
+                store: false,
+              }),
+              onError: () => {},
             })
-          }
+            for await (const part of result.fullStream) {
+              if (part.type === "error") throw part.error
+            }
+            return result.object
+          })
+        }
 
-          return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
-        }),
-      })
-    }),
-  )
+        return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
+      }),
+    })
+  }),
+)
 
-  export const defaultLayer = layer.pipe(
-    Layer.provide(Plugin.defaultLayer),
-    Layer.provide(Provider.defaultLayer),
-    Layer.provide(Auth.defaultLayer),
-    Layer.provide(Config.defaultLayer),
-    Layer.provide(Skill.defaultLayer),
-  )
-}
+export const defaultLayer = layer.pipe(
+  Layer.provide(Plugin.defaultLayer),
+  Layer.provide(Provider.defaultLayer),
+  Layer.provide(Auth.defaultLayer),
+  Layer.provide(Config.defaultLayer),
+  Layer.provide(Skill.defaultLayer),
+)
+
+export * as Agent from "./agent"

+ 0 - 89
packages/opencode/src/auth/auth.ts

@@ -1,89 +0,0 @@
-import path from "path"
-import { Effect, Layer, Record, Result, Schema, Context } from "effect"
-import { zod } from "@/util/effect-zod"
-import { Global } from "../global"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-
-export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
-
-const file = path.join(Global.Path.data, "auth.json")
-
-const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
-
-export class Oauth extends Schema.Class<Oauth>("OAuth")({
-  type: Schema.Literal("oauth"),
-  refresh: Schema.String,
-  access: Schema.String,
-  expires: Schema.Number,
-  accountId: Schema.optional(Schema.String),
-  enterpriseUrl: Schema.optional(Schema.String),
-}) {}
-
-export class Api extends Schema.Class<Api>("ApiAuth")({
-  type: Schema.Literal("api"),
-  key: Schema.String,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
-}) {}
-
-export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
-  type: Schema.Literal("wellknown"),
-  key: Schema.String,
-  token: Schema.String,
-}) {}
-
-const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
-export const Info = Object.assign(_Info, { zod: zod(_Info) })
-export type Info = Schema.Schema.Type<typeof _Info>
-
-export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
-  message: Schema.String,
-  cause: Schema.optional(Schema.Defect),
-}) {}
-
-export interface Interface {
-  readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
-  readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
-  readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
-  readonly remove: (key: string) => Effect.Effect<void, AuthError>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const fsys = yield* AppFileSystem.Service
-    const decode = Schema.decodeUnknownOption(Info)
-
-    const all = Effect.fn("Auth.all")(function* () {
-      const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
-      return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
-    })
-
-    const get = Effect.fn("Auth.get")(function* (providerID: string) {
-      return (yield* all())[providerID]
-    })
-
-    const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
-      const norm = key.replace(/\/+$/, "")
-      const data = yield* all()
-      if (norm !== key) delete data[key]
-      delete data[norm + "/"]
-      yield* fsys
-        .writeJson(file, { ...data, [norm]: info }, 0o600)
-        .pipe(Effect.mapError(fail("Failed to write auth data")))
-    })
-
-    const remove = Effect.fn("Auth.remove")(function* (key: string) {
-      const norm = key.replace(/\/+$/, "")
-      const data = yield* all()
-      delete data[key]
-      delete data[norm]
-      yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
-    })
-
-    return Service.of({ get, all, set, remove })
-  }),
-)
-
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

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

@@ -1,2 +1,97 @@
-export * as Auth from "./auth"
-export { OAUTH_DUMMY_KEY } from "./auth"
+import path from "path"
+import { Effect, Layer, Record, Result, Schema, Context } from "effect"
+import { zod } from "@/util/effect-zod"
+import { Global } from "../global"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+
+export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
+
+const file = path.join(Global.Path.data, "auth.json")
+
+const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
+
+export class Oauth extends Schema.Class<Oauth>("OAuth")({
+  type: Schema.Literal("oauth"),
+  refresh: Schema.String,
+  access: Schema.String,
+  expires: Schema.Number,
+  accountId: Schema.optional(Schema.String),
+  enterpriseUrl: Schema.optional(Schema.String),
+}) {}
+
+export class Api extends Schema.Class<Api>("ApiAuth")({
+  type: Schema.Literal("api"),
+  key: Schema.String,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+}) {}
+
+export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
+  type: Schema.Literal("wellknown"),
+  key: Schema.String,
+  token: Schema.String,
+}) {}
+
+const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
+export const Info = Object.assign(_Info, { zod: zod(_Info) })
+export type Info = Schema.Schema.Type<typeof _Info>
+
+export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
+  message: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export interface Interface {
+  readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
+  readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
+  readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
+  readonly remove: (key: string) => Effect.Effect<void, AuthError>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fsys = yield* AppFileSystem.Service
+    const decode = Schema.decodeUnknownOption(Info)
+
+    const all = Effect.fn("Auth.all")(function* () {
+      if (process.env.OPENCODE_AUTH_CONTENT) {
+        try {
+          return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
+        } catch (err) {}
+      }
+
+      const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
+      return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
+    })
+
+    const get = Effect.fn("Auth.get")(function* (providerID: string) {
+      return (yield* all())[providerID]
+    })
+
+    const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
+      const norm = key.replace(/\/+$/, "")
+      const data = yield* all()
+      if (norm !== key) delete data[key]
+      delete data[norm + "/"]
+      yield* fsys
+        .writeJson(file, { ...data, [norm]: info }, 0o600)
+        .pipe(Effect.mapError(fail("Failed to write auth data")))
+    })
+
+    const remove = Effect.fn("Auth.remove")(function* (key: string) {
+      const norm = key.replace(/\/+$/, "")
+      const data = yield* all()
+      delete data[key]
+      delete data[norm]
+      yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
+    })
+
+    return Service.of({ get, all, set, remove })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+export * as Auth from "."

+ 25 - 25
packages/opencode/src/bus/bus-event.ts

@@ -1,33 +1,33 @@
 import z from "zod"
 import type { ZodType } from "zod"
 
-export namespace BusEvent {
-  export type Definition = ReturnType<typeof define>
+export type Definition = ReturnType<typeof define>
 
-  const registry = new Map<string, Definition>()
+const registry = new Map<string, Definition>()
 
-  export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
-    const result = {
-      type,
-      properties,
-    }
-    registry.set(type, result)
-    return result
+export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
+  const result = {
+    type,
+    properties,
   }
+  registry.set(type, result)
+  return result
+}
 
-  export function payloads() {
-    return registry
-      .entries()
-      .map(([type, def]) => {
-        return z
-          .object({
-            type: z.literal(type),
-            properties: def.properties,
-          })
-          .meta({
-            ref: `Event.${def.type}`,
-          })
-      })
-      .toArray()
-  }
+export function payloads() {
+  return registry
+    .entries()
+    .map(([type, def]) => {
+      return z
+        .object({
+          type: z.literal(type),
+          properties: def.properties,
+        })
+        .meta({
+          ref: `Event.${def.type}`,
+        })
+    })
+    .toArray()
 }
+
+export * as BusEvent from "./bus-event"

+ 0 - 191
packages/opencode/src/bus/bus.ts

@@ -1,191 +0,0 @@
-import z from "zod"
-import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
-import { EffectBridge } from "@/effect"
-import { Log } from "../util"
-import { BusEvent } from "./bus-event"
-import { GlobalBus } from "./global"
-import { InstanceState } from "@/effect"
-import { makeRuntime } from "@/effect/run-service"
-
-const log = Log.create({ service: "bus" })
-
-export const InstanceDisposed = BusEvent.define(
-  "server.instance.disposed",
-  z.object({
-    directory: z.string(),
-  }),
-)
-
-type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
-  type: D["type"]
-  properties: z.infer<D["properties"]>
-}
-
-type State = {
-  wildcard: PubSub.PubSub<Payload>
-  typed: Map<string, PubSub.PubSub<Payload>>
-}
-
-export interface Interface {
-  readonly publish: <D extends BusEvent.Definition>(
-    def: D,
-    properties: z.output<D["properties"]>,
-  ) => Effect.Effect<void>
-  readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
-  readonly subscribeAll: () => Stream.Stream<Payload>
-  readonly subscribeCallback: <D extends BusEvent.Definition>(
-    def: D,
-    callback: (event: Payload<D>) => unknown,
-  ) => Effect.Effect<() => void>
-  readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const state = yield* InstanceState.make<State>(
-      Effect.fn("Bus.state")(function* (ctx) {
-        const wildcard = yield* PubSub.unbounded<Payload>()
-        const typed = new Map<string, PubSub.PubSub<Payload>>()
-
-        yield* Effect.addFinalizer(() =>
-          Effect.gen(function* () {
-            // Publish InstanceDisposed before shutting down so subscribers see it
-            yield* PubSub.publish(wildcard, {
-              type: InstanceDisposed.type,
-              properties: { directory: ctx.directory },
-            })
-            yield* PubSub.shutdown(wildcard)
-            for (const ps of typed.values()) {
-              yield* PubSub.shutdown(ps)
-            }
-          }),
-        )
-
-        return { wildcard, typed }
-      }),
-    )
-
-    function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
-      return Effect.gen(function* () {
-        let ps = state.typed.get(def.type)
-        if (!ps) {
-          ps = yield* PubSub.unbounded<Payload>()
-          state.typed.set(def.type, ps)
-        }
-        return ps as unknown as PubSub.PubSub<Payload<D>>
-      })
-    }
-
-    function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
-      return Effect.gen(function* () {
-        const s = yield* InstanceState.get(state)
-        const payload: Payload = { type: def.type, properties }
-        log.info("publishing", { type: def.type })
-
-        const ps = s.typed.get(def.type)
-        if (ps) yield* PubSub.publish(ps, payload)
-        yield* PubSub.publish(s.wildcard, payload)
-
-        const dir = yield* InstanceState.directory
-        const context = yield* InstanceState.context
-        const workspace = yield* InstanceState.workspaceID
-
-        GlobalBus.emit("event", {
-          directory: dir,
-          project: context.project.id,
-          workspace,
-          payload,
-        })
-      })
-    }
-
-    function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
-      log.info("subscribing", { type: def.type })
-      return Stream.unwrap(
-        Effect.gen(function* () {
-          const s = yield* InstanceState.get(state)
-          const ps = yield* getOrCreate(s, def)
-          return Stream.fromPubSub(ps)
-        }),
-      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
-    }
-
-    function subscribeAll(): Stream.Stream<Payload> {
-      log.info("subscribing", { type: "*" })
-      return Stream.unwrap(
-        Effect.gen(function* () {
-          const s = yield* InstanceState.get(state)
-          return Stream.fromPubSub(s.wildcard)
-        }),
-      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
-    }
-
-    function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
-      return Effect.gen(function* () {
-        log.info("subscribing", { type })
-        const bridge = yield* EffectBridge.make()
-        const scope = yield* Scope.make()
-        const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
-
-        yield* Scope.provide(scope)(
-          Stream.fromSubscription(subscription).pipe(
-            Stream.runForEach((msg) =>
-              Effect.tryPromise({
-                try: () => Promise.resolve().then(() => callback(msg)),
-                catch: (cause) => {
-                  log.error("subscriber failed", { type, cause })
-                },
-              }).pipe(Effect.ignore),
-            ),
-            Effect.forkScoped,
-          ),
-        )
-
-        return () => {
-          log.info("unsubscribing", { type })
-          bridge.fork(Scope.close(scope, Exit.void))
-        }
-      })
-    }
-
-    const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
-      def: D,
-      callback: (event: Payload<D>) => unknown,
-    ) {
-      const s = yield* InstanceState.get(state)
-      const ps = yield* getOrCreate(s, def)
-      return yield* on(ps, def.type, callback)
-    })
-
-    const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
-      const s = yield* InstanceState.get(state)
-      return yield* on(s.wildcard, "*", callback)
-    })
-
-    return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
-  }),
-)
-
-export const defaultLayer = layer
-
-const { runPromise, runSync } = makeRuntime(Service, layer)
-
-// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
-// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
-export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
-  return runPromise((svc) => svc.publish(def, properties))
-}
-
-export function subscribe<D extends BusEvent.Definition>(
-  def: D,
-  callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
-) {
-  return runSync((svc) => svc.subscribeCallback(def, callback))
-}
-
-export function subscribeAll(callback: (event: any) => unknown) {
-  return runSync((svc) => svc.subscribeAllCallback(callback))
-}

+ 193 - 1
packages/opencode/src/bus/index.ts

@@ -1 +1,193 @@
-export * as Bus from "./bus"
+import z from "zod"
+import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
+import { EffectBridge } from "@/effect"
+import { Log } from "../util"
+import { BusEvent } from "./bus-event"
+import { GlobalBus } from "./global"
+import { InstanceState } from "@/effect"
+import { makeRuntime } from "@/effect/run-service"
+
+const log = Log.create({ service: "bus" })
+
+export const InstanceDisposed = BusEvent.define(
+  "server.instance.disposed",
+  z.object({
+    directory: z.string(),
+  }),
+)
+
+type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
+  type: D["type"]
+  properties: z.infer<D["properties"]>
+}
+
+type State = {
+  wildcard: PubSub.PubSub<Payload>
+  typed: Map<string, PubSub.PubSub<Payload>>
+}
+
+export interface Interface {
+  readonly publish: <D extends BusEvent.Definition>(
+    def: D,
+    properties: z.output<D["properties"]>,
+  ) => Effect.Effect<void>
+  readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
+  readonly subscribeAll: () => Stream.Stream<Payload>
+  readonly subscribeCallback: <D extends BusEvent.Definition>(
+    def: D,
+    callback: (event: Payload<D>) => unknown,
+  ) => Effect.Effect<() => void>
+  readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Bus.state")(function* (ctx) {
+        const wildcard = yield* PubSub.unbounded<Payload>()
+        const typed = new Map<string, PubSub.PubSub<Payload>>()
+
+        yield* Effect.addFinalizer(() =>
+          Effect.gen(function* () {
+            // Publish InstanceDisposed before shutting down so subscribers see it
+            yield* PubSub.publish(wildcard, {
+              type: InstanceDisposed.type,
+              properties: { directory: ctx.directory },
+            })
+            yield* PubSub.shutdown(wildcard)
+            for (const ps of typed.values()) {
+              yield* PubSub.shutdown(ps)
+            }
+          }),
+        )
+
+        return { wildcard, typed }
+      }),
+    )
+
+    function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
+      return Effect.gen(function* () {
+        let ps = state.typed.get(def.type)
+        if (!ps) {
+          ps = yield* PubSub.unbounded<Payload>()
+          state.typed.set(def.type, ps)
+        }
+        return ps as unknown as PubSub.PubSub<Payload<D>>
+      })
+    }
+
+    function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+      return Effect.gen(function* () {
+        const s = yield* InstanceState.get(state)
+        const payload: Payload = { type: def.type, properties }
+        log.info("publishing", { type: def.type })
+
+        const ps = s.typed.get(def.type)
+        if (ps) yield* PubSub.publish(ps, payload)
+        yield* PubSub.publish(s.wildcard, payload)
+
+        const dir = yield* InstanceState.directory
+        const context = yield* InstanceState.context
+        const workspace = yield* InstanceState.workspaceID
+
+        GlobalBus.emit("event", {
+          directory: dir,
+          project: context.project.id,
+          workspace,
+          payload,
+        })
+      })
+    }
+
+    function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
+      log.info("subscribing", { type: def.type })
+      return Stream.unwrap(
+        Effect.gen(function* () {
+          const s = yield* InstanceState.get(state)
+          const ps = yield* getOrCreate(s, def)
+          return Stream.fromPubSub(ps)
+        }),
+      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
+    }
+
+    function subscribeAll(): Stream.Stream<Payload> {
+      log.info("subscribing", { type: "*" })
+      return Stream.unwrap(
+        Effect.gen(function* () {
+          const s = yield* InstanceState.get(state)
+          return Stream.fromPubSub(s.wildcard)
+        }),
+      ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
+    }
+
+    function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
+      return Effect.gen(function* () {
+        log.info("subscribing", { type })
+        const bridge = yield* EffectBridge.make()
+        const scope = yield* Scope.make()
+        const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
+
+        yield* Scope.provide(scope)(
+          Stream.fromSubscription(subscription).pipe(
+            Stream.runForEach((msg) =>
+              Effect.tryPromise({
+                try: () => Promise.resolve().then(() => callback(msg)),
+                catch: (cause) => {
+                  log.error("subscriber failed", { type, cause })
+                },
+              }).pipe(Effect.ignore),
+            ),
+            Effect.forkScoped,
+          ),
+        )
+
+        return () => {
+          log.info("unsubscribing", { type })
+          bridge.fork(Scope.close(scope, Exit.void))
+        }
+      })
+    }
+
+    const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
+      def: D,
+      callback: (event: Payload<D>) => unknown,
+    ) {
+      const s = yield* InstanceState.get(state)
+      const ps = yield* getOrCreate(s, def)
+      return yield* on(ps, def.type, callback)
+    })
+
+    const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
+      const s = yield* InstanceState.get(state)
+      return yield* on(s.wildcard, "*", callback)
+    })
+
+    return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
+  }),
+)
+
+export const defaultLayer = layer
+
+const { runPromise, runSync } = makeRuntime(Service, layer)
+
+// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
+export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+  return runPromise((svc) => svc.publish(def, properties))
+}
+
+export function subscribe<D extends BusEvent.Definition>(
+  def: D,
+  callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
+) {
+  return runSync((svc) => svc.subscribeCallback(def, callback))
+}
+
+export function subscribeAll(callback: (event: any) => unknown) {
+  return runSync((svc) => svc.subscribeAllCallback(callback))
+}
+
+export * as Bus from "."

+ 2 - 2
packages/opencode/src/cli/cmd/account.ts

@@ -1,8 +1,8 @@
 import { cmd } from "./cmd"
 import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
-import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
-import { type AccountError } from "@/account/schema"
+import { Account } from "@/account/account"
+import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
 import { AppRuntime } from "@/effect/app-runtime"
 import * as Prompt from "../effect/prompt"
 import open from "open"

+ 13 - 1
packages/opencode/src/cli/cmd/generate.ts

@@ -25,7 +25,19 @@ export const GenerateCommand = {
         ]
       }
     }
-    const json = JSON.stringify(specs, null, 2)
+    const raw = JSON.stringify(specs, null, 2)
+
+    // Format through prettier so output is byte-identical to committed file
+    // regardless of whether ./script/format.ts runs afterward.
+    const prettier = await import("prettier")
+    const babel = await import("prettier/plugins/babel")
+    const estree = await import("prettier/plugins/estree")
+    const format = prettier.format ?? prettier.default?.format
+    const json = await format(raw, {
+      parser: "json",
+      plugins: [babel.default ?? babel, estree.default ?? estree],
+      printWidth: 120,
+    })
 
     // Wait for stdout to finish writing before process.exit() is called
     await new Promise<void>((resolve, reject) => {

+ 5 - 4
packages/opencode/src/cli/cmd/mcp.ts

@@ -8,6 +8,7 @@ import { MCP } from "../../mcp"
 import { McpAuth } from "../../mcp/auth"
 import { McpOAuthProvider } from "../../mcp/oauth-provider"
 import { Config } from "../../config"
+import { ConfigMCP } from "../../config/mcp"
 import { Instance } from "../../project/instance"
 import { Installation } from "../../installation"
 import { InstallationVersion } from "../../installation/version"
@@ -43,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
 
 type McpEntry = NonNullable<Config.Info["mcp"]>[string]
 
-type McpConfigured = Config.Mcp
+type McpConfigured = ConfigMCP.Info
 function isMcpConfigured(config: McpEntry): config is McpConfigured {
   return typeof config === "object" && config !== null && "type" in config
 }
@@ -426,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
   return candidates[0]
 }
 
-async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
+async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
   let text = "{}"
   if (await Filesystem.exists(configPath)) {
     text = await Filesystem.readText(configPath)
@@ -514,7 +515,7 @@ export const McpAddCommand = cmd({
           })
           if (prompts.isCancel(command)) throw new UI.CancelledError()
 
-          const mcpConfig: Config.Mcp = {
+          const mcpConfig: ConfigMCP.Info = {
             type: "local",
             command: command.split(" "),
           }
@@ -544,7 +545,7 @@ export const McpAddCommand = cmd({
           })
           if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
 
-          let mcpConfig: Config.Mcp
+          let mcpConfig: ConfigMCP.Info
 
           if (useOAuth) {
             const hasClientId = await prompts.confirm({

+ 3 - 1
packages/opencode/src/cli/cmd/providers.ts

@@ -297,7 +297,9 @@ export const ProvidersLoginCommand = cmd({
         prompts.intro("Add credential")
         if (args.url) {
           const url = args.url.replace(/\/+$/, "")
-          const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
+          const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
+            auth: { command: string[]; env: string }
+          }
           prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
           const proc = Process.spawn(wellknown.auth.command, {
             stdout: "pipe",

+ 11 - 7
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -148,7 +148,16 @@ export function tui(input: {
             <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
               <KVProvider>
                 <ToastProvider>
-                  <RouteProvider>
+                  <RouteProvider
+                    initialRoute={
+                      input.args.continue
+                        ? {
+                            type: "session",
+                            sessionID: "dummy",
+                          }
+                        : undefined
+                    }
+                  >
                     <TuiConfigProvider config={input.config}>
                       <SDKProvider
                         url={input.url}
@@ -333,7 +342,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
           })
         local.model.set({ providerID, modelID }, { recent: true })
       }
-      // Handle --session without --fork immediately (fork is handled in createEffect below)
       if (args.sessionID && !args.fork) {
         route.navigate({
           type: "session",
@@ -420,12 +428,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         aliases: ["clear"],
       },
       onSelect: () => {
-        const current = promptRef.current
-        // Don't require focus - if there's any text, preserve it
-        const currentPrompt = current?.current?.input ? current.current : undefined
         route.navigate({
           type: "home",
-          initialPrompt: currentPrompt,
         })
         dialog.clear()
       },
@@ -602,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
       category: "System",
     },
     {
-      title: "Toggle theme mode",
+      title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
       value: "theme.switch_mode",
       onSelect: (dialog) => {
         setMode(mode() === "dark" ? "light" : "dark")

+ 130 - 0
packages/opencode/src/cli/cmd/tui/component/bg-pulse.tsx

@@ -0,0 +1,130 @@
+import { BoxRenderable, RGBA } from "@opentui/core"
+import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
+import { tint, useTheme } from "@tui/context/theme"
+
+const PERIOD = 4600
+const RINGS = 3
+const WIDTH = 3.8
+const TAIL = 9.5
+const AMP = 0.55
+const TAIL_AMP = 0.16
+const BREATH_AMP = 0.05
+const BREATH_SPEED = 0.0008
+// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
+const PHASE_OFFSET = 0.29
+
+export type BgPulseMask = {
+  x: number
+  y: number
+  width: number
+  height: number
+  pad?: number
+  strength?: number
+}
+
+export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
+  const { theme } = useTheme()
+  const [now, setNow] = createSignal(performance.now())
+  const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
+  let box: BoxRenderable | undefined
+
+  const timer = setInterval(() => setNow(performance.now()), 50)
+  onCleanup(() => clearInterval(timer))
+
+  const sync = () => {
+    if (!box) return
+    setSize({ width: box.width, height: box.height })
+  }
+
+  onMount(() => {
+    sync()
+    box?.on("resize", sync)
+  })
+
+  onCleanup(() => {
+    box?.off("resize", sync)
+  })
+
+  const grid = createMemo(() => {
+    const t = now()
+    const w = size().width
+    const h = size().height
+    if (w === 0 || h === 0) return [] as RGBA[][]
+    const cxv = props.centerX ?? w / 2
+    const cyv = props.centerY ?? h / 2
+    const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
+    const ringStates = Array.from({ length: RINGS }, (_, i) => {
+      const offset = i / RINGS
+      const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
+      const envelope = Math.sin(phase * Math.PI)
+      const eased = envelope * envelope * (3 - 2 * envelope)
+      return {
+        head: phase * reach,
+        eased,
+      }
+    })
+    const normalizedMasks = props.masks?.map((m) => {
+      const pad = m.pad ?? 2
+      return {
+        left: m.x - pad,
+        right: m.x + m.width + pad,
+        top: m.y - pad,
+        bottom: m.y + m.height + pad,
+        pad,
+        strength: m.strength ?? 0.85,
+      }
+    })
+    const rows = [] as RGBA[][]
+    for (let y = 0; y < h; y++) {
+      const row = [] as RGBA[]
+      for (let x = 0; x < w; x++) {
+        const dx = x + 0.5 - cxv
+        const dy = (y + 0.5 - cyv) * 2
+        const dist = Math.hypot(dx, dy)
+        let level = 0
+        for (const ring of ringStates) {
+          const delta = dist - ring.head
+          const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
+          const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
+          level += (crest * AMP + tail * TAIL_AMP) * ring.eased
+        }
+        const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
+        const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
+        let maskAtten = 1
+        if (normalizedMasks) {
+          for (const m of normalizedMasks) {
+            if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
+            const inX = Math.min(x - m.left, m.right - x)
+            const inY = Math.min(y - m.top, m.bottom - y)
+            const edge = Math.min(inX / m.pad, inY / m.pad, 1)
+            const eased = edge * edge * (3 - 2 * edge)
+            const reduce = 1 - m.strength * eased
+            if (reduce < maskAtten) maskAtten = reduce
+          }
+        }
+        const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
+        row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
+      }
+      rows.push(row)
+    }
+    return rows
+  })
+
+  return (
+    <box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
+      <For each={grid()}>
+        {(row) => (
+          <box flexDirection="row">
+            <For each={row}>
+              {(color) => (
+                <text bg={color} fg={color} selectable={false}>
+                  {" "}
+                </text>
+              )}
+            </For>
+          </box>
+        )}
+      </For>
+    </box>
+  )
+}

+ 1 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

@@ -63,6 +63,7 @@ function init() {
   useKeyboard((evt) => {
     if (suspended()) return
     if (dialog.stack.length > 0) return
+    if (evt.defaultPrevented) return
     for (const option of entries()) {
       if (!isEnabled(option)) continue
       if (option.keybind && keybind.match(option.keybind, evt)) {

+ 104 - 46
packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx

@@ -1,12 +1,16 @@
-import { RGBA, TextAttributes } from "@opentui/core"
+import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
 import { useKeyboard } from "@opentui/solid"
 import open from "open"
-import { createSignal } from "solid-js"
+import { createSignal, onCleanup, onMount } from "solid-js"
 import { selectedForeground, useTheme } from "@tui/context/theme"
 import { useDialog, type DialogContext } from "@tui/ui/dialog"
 import { Link } from "@tui/ui/link"
+import { GoLogo } from "./logo"
+import { BgPulse, type BgPulseMask } from "./bg-pulse"
 
 const GO_URL = "https://opencode.ai/go"
+const PAD_X = 3
+const PAD_TOP_OUTER = 1
 
 export type DialogGoUpsellProps = {
   onClose?: (dontShowAgain?: boolean) => void
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
   const dialog = useDialog()
   const { theme } = useTheme()
   const fg = selectedForeground(theme)
-  const [selected, setSelected] = createSignal(0)
+  const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
+  const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
+  const [masks, setMasks] = createSignal<BgPulseMask[]>([])
+  let content: BoxRenderable | undefined
+  let logoBox: BoxRenderable | undefined
+  let headingBox: BoxRenderable | undefined
+  let descBox: BoxRenderable | undefined
+  let buttonsBox: BoxRenderable | undefined
+
+  const sync = () => {
+    if (!content || !logoBox) return
+    setCenter({
+      x: logoBox.x - content.x + logoBox.width / 2,
+      y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
+    })
+    const next: BgPulseMask[] = []
+    const baseY = PAD_TOP_OUTER
+    for (const b of [headingBox, descBox, buttonsBox]) {
+      if (!b) continue
+      next.push({
+        x: b.x - content.x,
+        y: b.y - content.y + baseY,
+        width: b.width,
+        height: b.height,
+        pad: 2,
+        strength: 0.78,
+      })
+    }
+    setMasks(next)
+  }
+
+  onMount(() => {
+    sync()
+    for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
+  })
+
+  onCleanup(() => {
+    for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
+  })
 
   useKeyboard((evt) => {
     if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
-      setSelected((s) => (s === 0 ? 1 : 0))
+      setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
       return
     }
-    if (evt.name !== "return") return
-    if (selected() === 0) subscribe(props, dialog)
-    else dismiss(props, dialog)
+    if (evt.name === "return") {
+      if (selected() === "subscribe") subscribe(props, dialog)
+      else dismiss(props, dialog)
+    }
   })
 
   return (
-    <box paddingLeft={2} paddingRight={2} gap={1}>
-      <box flexDirection="row" justifyContent="space-between">
-        <text attributes={TextAttributes.BOLD} fg={theme.text}>
-          Free limit reached
-        </text>
-        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
-          esc
-        </text>
-      </box>
-      <box gap={1} paddingBottom={1}>
-        <text fg={theme.textMuted}>
-          Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
-          $5/month.
-        </text>
-        <box flexDirection="row" gap={1}>
-          <Link href={GO_URL} fg={theme.primary} />
-        </box>
+    <box ref={(item: BoxRenderable) => (content = item)}>
+      <box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
+        <BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
       </box>
-      <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
-        <box
-          paddingLeft={3}
-          paddingRight={3}
-          backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-          onMouseOver={() => setSelected(0)}
-          onMouseUp={() => subscribe(props, dialog)}
-        >
-          <text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
-            subscribe
+      <box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
+        <box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
+          <text attributes={TextAttributes.BOLD} fg={theme.text}>
+            Free limit reached
           </text>
+          <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+            esc
+          </text>
+        </box>
+        <box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
+          <box flexDirection="row">
+            <text fg={theme.textMuted}>Subscribe to </text>
+            <text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
+              OpenCode Go
+            </text>
+            <text fg={theme.textMuted}> for reliable access to the</text>
+          </box>
+          <text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
         </box>
-        <box
-          paddingLeft={3}
-          paddingRight={3}
-          backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-          onMouseOver={() => setSelected(1)}
-          onMouseUp={() => dismiss(props, dialog)}
-        >
-          <text
-            fg={selected() === 1 ? fg : theme.textMuted}
-            attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
+        <box alignItems="center" gap={1} paddingBottom={1}>
+          <box ref={(item: BoxRenderable) => (logoBox = item)}>
+            <GoLogo />
+          </box>
+          <Link href={GO_URL} fg={theme.primary} />
+        </box>
+        <box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
+          <box
+            paddingLeft={2}
+            paddingRight={2}
+            backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+            onMouseOver={() => setSelected("dismiss")}
+            onMouseUp={() => dismiss(props, dialog)}
           >
-            don't show again
-          </text>
+            <text
+              fg={selected() === "dismiss" ? fg : theme.textMuted}
+              attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
+            >
+              don't show again
+            </text>
+          </box>
+          <box
+            paddingLeft={2}
+            paddingRight={2}
+            backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+            onMouseOver={() => setSelected("subscribe")}
+            onMouseUp={() => subscribe(props, dialog)}
+          >
+            <text
+              fg={selected() === "subscribe" ? fg : theme.text}
+              attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
+            >
+              subscribe
+            </text>
+          </box>
         </box>
       </box>
     </box>

+ 101 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx

@@ -0,0 +1,101 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+
+export function DialogSessionDeleteFailed(props: {
+  session: string
+  workspace: string
+  onDelete?: () => boolean | void | Promise<boolean | void>
+  onRestore?: () => boolean | void | Promise<boolean | void>
+  onDone?: () => void
+}) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  const [store, setStore] = createStore({
+    active: "delete" as "delete" | "restore",
+  })
+
+  const options = [
+    {
+      id: "delete" as const,
+      title: "Delete workspace",
+      description: "Delete the workspace and all sessions attached to it.",
+      run: props.onDelete,
+    },
+    {
+      id: "restore" as const,
+      title: "Restore to new workspace",
+      description: "Try to restore this session into a new workspace.",
+      run: props.onRestore,
+    },
+  ]
+
+  async function confirm() {
+    const result = await options.find((item) => item.id === store.active)?.run?.()
+    if (result === false) return
+    props.onDone?.()
+    if (!props.onDone) dialog.clear()
+  }
+
+  useKeyboard((evt) => {
+    if (evt.name === "return") {
+      void confirm()
+    }
+    if (evt.name === "left" || evt.name === "up") {
+      setStore("active", "delete")
+    }
+    if (evt.name === "right" || evt.name === "down") {
+      setStore("active", "restore")
+    }
+  })
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Failed to Delete Session
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <text fg={theme.textMuted} wrapMode="word">
+        {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
+      </text>
+      <text fg={theme.textMuted} wrapMode="word">
+        Choose how you want to recover this broken workspace session.
+      </text>
+      <box flexDirection="column" paddingBottom={1} gap={1}>
+        <For each={options}>
+          {(item) => (
+            <box
+              flexDirection="column"
+              paddingLeft={1}
+              paddingRight={1}
+              paddingTop={1}
+              paddingBottom={1}
+              backgroundColor={item.id === store.active ? theme.primary : undefined}
+              onMouseUp={() => {
+                setStore("active", item.id)
+                void confirm()
+              }}
+            >
+              <text
+                attributes={TextAttributes.BOLD}
+                fg={item.id === store.active ? theme.selectedListItemText : theme.text}
+              >
+                {item.title}
+              </text>
+              <text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
+                {item.description}
+              </text>
+            </box>
+          )}
+        </For>
+      </box>
+    </box>
+  )
+}

+ 99 - 13
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
 import { Keybind } from "@/util"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
-import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
+import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
 import { Spinner } from "./spinner"
+import { errorMessage } from "@/util/error"
+import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
 
 type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
 
@@ -30,7 +32,7 @@ export function DialogSessionList() {
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
 
-  const [searchResults] = createResource(search, async (query) => {
+  const [searchResults, { refetch }] = createResource(search, async (query) => {
     if (!query) return undefined
     const result = await sdk.client.session.list({ search: query, limit: 30 })
     return result.data ?? []
@@ -56,11 +58,66 @@ export function DialogSessionList() {
     ))
   }
 
+  function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
+    const workspace = project.workspace.get(session.workspaceID!)
+    const list = () => dialog.replace(() => <DialogSessionList />)
+    dialog.replace(() => (
+      <DialogSessionDeleteFailed
+        session={session.title}
+        workspace={workspace?.name ?? session.workspaceID!}
+        onDone={list}
+        onDelete={async () => {
+          const current = currentSessionID()
+          const info = current ? sync.data.session.find((item) => item.id === current) : undefined
+          const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
+          if (result.error) {
+            toast.show({
+              variant: "error",
+              title: "Failed to delete workspace",
+              message: errorMessage(result.error),
+            })
+            return false
+          }
+          await project.workspace.sync()
+          await sync.session.refresh()
+          if (search()) await refetch()
+          if (info?.workspaceID === session.workspaceID) {
+            route.navigate({ type: "home" })
+          }
+          return true
+        }}
+        onRestore={() => {
+          dialog.replace(() => (
+            <DialogWorkspaceCreate
+              onSelect={(workspaceID) =>
+                restoreWorkspaceSession({
+                  dialog,
+                  sdk,
+                  sync,
+                  project,
+                  toast,
+                  workspaceID,
+                  sessionID: session.id,
+                  done: list,
+                })
+              }
+            />
+          ))
+          return false
+        }}
+      />
+    ))
+  }
+
   const options = createMemo(() => {
     const today = new Date().toDateString()
     return sessions()
       .filter((x) => x.parentID === undefined)
-      .toSorted((a, b) => b.time.updated - a.time.updated)
+      .toSorted((a, b) => {
+        const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
+        if (updatedDay !== 0) return updatedDay
+        return b.time.created - a.time.created
+      })
       .map((x) => {
         const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
 
@@ -82,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>
               </>
             )
@@ -145,9 +197,43 @@ export function DialogSessionList() {
           title: "delete",
           onTrigger: async (option) => {
             if (toDelete() === option.value) {
-              void sdk.client.session.delete({
-                sessionID: option.value,
-              })
+              const session = sessions().find((item) => item.id === option.value)
+              const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
+
+              try {
+                const result = await sdk.client.session.delete({
+                  sessionID: option.value,
+                })
+                if (result.error) {
+                  if (session?.workspaceID) {
+                    recover(session)
+                  } else {
+                    toast.show({
+                      variant: "error",
+                      title: "Failed to delete session",
+                      message: errorMessage(result.error),
+                    })
+                  }
+                  setToDelete(undefined)
+                  return
+                }
+              } catch (err) {
+                if (session?.workspaceID) {
+                  recover(session)
+                } else {
+                  toast.show({
+                    variant: "error",
+                    title: "Failed to delete session",
+                    message: errorMessage(err),
+                  })
+                }
+                setToDelete(undefined)
+                return
+              }
+              if (status && status !== "connected") {
+                await sync.session.refresh()
+              }
+              if (search()) await refetch()
               setToDelete(undefined)
               return
             }

+ 140 - 4
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
 import { useProject } from "@tui/context/project"
 import { createMemo, createSignal, onMount } from "solid-js"
 import { setTimeout as sleep } from "node:timers/promises"
+import { errorData, errorMessage } from "@/util/error"
+import * as Log from "@/util/log"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
 
@@ -15,6 +17,8 @@ type Adaptor = {
   description: string
 }
 
+const log = Log.Default.clone().tag("service", "tui-workspace")
+
 function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
   return createOpencodeClient({
     baseUrl: sdk.url,
@@ -33,8 +37,18 @@ export async function openWorkspaceSession(input: {
   workspaceID: string
 }) {
   const client = scoped(input.sdk, input.sync, input.workspaceID)
+  log.info("workspace session create requested", {
+    workspaceID: input.workspaceID,
+  })
+
   while (true) {
-    const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
+    const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
+      log.error("workspace session create request failed", {
+        workspaceID: input.workspaceID,
+        error: errorData(err),
+      })
+      return undefined
+    })
     if (!result) {
       input.toast.show({
         message: "Failed to create workspace session",
@@ -42,26 +56,119 @@ export async function openWorkspaceSession(input: {
       })
       return
     }
-    if (result.response.status >= 500 && result.response.status < 600) {
+    log.info("workspace session create response", {
+      workspaceID: input.workspaceID,
+      status: result.response?.status,
+      sessionID: result.data?.id,
+    })
+    if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
+      log.warn("workspace session create retrying after server error", {
+        workspaceID: input.workspaceID,
+        status: result.response.status,
+      })
       await sleep(1000)
       continue
     }
     if (!result.data) {
+      log.error("workspace session create returned no data", {
+        workspaceID: input.workspaceID,
+        status: result.response?.status,
+      })
       input.toast.show({
         message: "Failed to create workspace session",
         variant: "error",
       })
       return
     }
+
     input.route.navigate({
       type: "session",
       sessionID: result.data.id,
     })
+    log.info("workspace session create complete", {
+      workspaceID: input.workspaceID,
+      sessionID: result.data.id,
+    })
     input.dialog.clear()
     return
   }
 }
 
+export async function restoreWorkspaceSession(input: {
+  dialog: ReturnType<typeof useDialog>
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  project: ReturnType<typeof useProject>
+  toast: ReturnType<typeof useToast>
+  workspaceID: string
+  sessionID: string
+  done?: () => void
+}) {
+  log.info("session restore requested", {
+    workspaceID: input.workspaceID,
+    sessionID: input.sessionID,
+  })
+  const result = await input.sdk.client.experimental.workspace
+    .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
+    .catch((err) => {
+      log.error("session restore request failed", {
+        workspaceID: input.workspaceID,
+        sessionID: input.sessionID,
+        error: errorData(err),
+      })
+      return undefined
+    })
+  if (!result?.data) {
+    log.error("session restore failed", {
+      workspaceID: input.workspaceID,
+      sessionID: input.sessionID,
+      status: result?.response?.status,
+      error: result?.error ? errorData(result.error) : undefined,
+    })
+    input.toast.show({
+      message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
+      variant: "error",
+    })
+    return
+  }
+
+  log.info("session restore response", {
+    workspaceID: input.workspaceID,
+    sessionID: input.sessionID,
+    status: result.response?.status,
+    total: result.data.total,
+  })
+
+  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,
+      error: errorData(err),
+    })
+    throw err
+  })
+
+  log.info("session restore complete", {
+    workspaceID: input.workspaceID,
+    sessionID: input.sessionID,
+    total: result.data.total,
+  })
+
+  input.toast.show({
+    message: "Session restored into the new workspace",
+    variant: "success",
+  })
+  input.done?.()
+  if (input.done) return
+  input.dialog.clear()
+}
+
 export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
   const dialog = useDialog()
   const sync = useSync()
@@ -123,18 +230,47 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
   const create = async (type: string) => {
     if (creating()) return
     setCreating(type)
+    log.info("workspace create requested", {
+      type,
+    })
+
+    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
+      toast.show({
+        message: "Creating workspace failed",
+        variant: "error",
+      })
+      log.error("workspace create request failed", {
+        type,
+        error: errorData(err),
+      })
+      return undefined
+    })
 
-    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
     const workspace = result?.data
     if (!workspace) {
       setCreating(undefined)
+      log.error("workspace create failed", {
+        type,
+        status: result?.response.status,
+        error: result?.error ? errorData(result.error) : undefined,
+      })
       toast.show({
-        message: "Failed to create workspace",
+        message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
         variant: "error",
       })
       return
     }
+    log.info("workspace create response", {
+      type,
+      workspaceID: workspace.id,
+      status: result.response?.status,
+    })
+
     await project.workspace.sync()
+    log.info("workspace create synced", {
+      type,
+      workspaceID: workspace.id,
+    })
     await props.onSelect(workspace.id)
     setCreating(undefined)
   }

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

+ 321 - 61
packages/opencode/src/cli/cmd/tui/component/logo.tsx

@@ -1,8 +1,61 @@
 import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
-import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
+import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
 import { useTheme, tint } from "@tui/context/theme"
 import * as Sound from "@tui/util/sound"
-import { logo } from "@/cli/logo"
+import { go, logo } from "@/cli/logo"
+
+export type LogoShape = {
+  left: string[]
+  right: string[]
+}
+
+type ShimmerConfig = {
+  period: number
+  rings: number
+  sweepFraction: number
+  coreWidth: number
+  coreAmp: number
+  softWidth: number
+  softAmp: number
+  tail: number
+  tailAmp: number
+  haloWidth: number
+  haloOffset: number
+  haloAmp: number
+  breathBase: number
+  noise: number
+  ambientAmp: number
+  ambientCenter: number
+  ambientWidth: number
+  shadowMix: number
+  primaryMix: number
+  originX: number
+  originY: number
+}
+
+const shimmerConfig: ShimmerConfig = {
+  period: 4600,
+  rings: 2,
+  sweepFraction: 1,
+  coreWidth: 1.2,
+  coreAmp: 1.9,
+  softWidth: 10,
+  softAmp: 1.6,
+  tail: 5,
+  tailAmp: 0.64,
+  haloWidth: 4.3,
+  haloOffset: 0.6,
+  haloAmp: 0.16,
+  breathBase: 0.04,
+  noise: 0.1,
+  ambientAmp: 0.36,
+  ambientCenter: 0.5,
+  ambientWidth: 0.34,
+  shadowMix: 0.1,
+  primaryMix: 0.3,
+  originX: 4.5,
+  originY: 13.5,
+}
 
 // Shadow markers (rendered chars in parens):
 // _ = full shadow cell (space with bg=shadow)
@@ -74,9 +127,6 @@ type Frame = {
   spark: number
 }
 
-const LEFT = logo.left[0]?.length ?? 0
-const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
-const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
 const NEAR = [
   [1, 0],
   [1, 1],
@@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
 }
 
 function lit(char: string) {
-  return char !== " " && char !== "_" && char !== "~"
+  return char !== " " && char !== "_" && char !== "~" && char !== ","
 }
 
 function key(x: number, y: number) {
@@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
   return path
 }
 
-function mapGlyphs() {
+function mapGlyphs(full: string[]) {
   const cells = [] as Array<{ x: number; y: number }>
 
-  for (let y = 0; y < FULL.length; y++) {
-    for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
-      if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
+  for (let y = 0; y < full.length; y++) {
+    for (let x = 0; x < (full[y]?.length ?? 0); x++) {
+      if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
     }
   }
 
@@ -237,9 +287,25 @@ function mapGlyphs() {
   return { glyph, trace, center }
 }
 
-const MAP = mapGlyphs()
+type LogoContext = {
+  LEFT: number
+  FULL: string[]
+  SPAN: number
+  MAP: ReturnType<typeof mapGlyphs>
+  shape: LogoShape
+}
+
+function build(shape: LogoShape): LogoContext {
+  const LEFT = shape.left[0]?.length ?? 0
+  const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
+  const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
+  return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
+}
+
+const DEFAULT = build(logo)
+const GO = build(go)
 
-function shimmer(x: number, y: number, frame: Frame) {
+function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
   return frame.list.reduce((best, item) => {
     const age = frame.t - item.at
     if (age < SHIMMER_IN || age > LIFE) return best
@@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
     const dy = y * 2 + 1 - item.y
     const dist = Math.hypot(dx, dy)
     const p = age / LIFE
-    const r = SPAN * (1 - (1 - p) ** EXPAND)
+    const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
     const lag = r - dist
     if (lag < 0.18 || lag > SHIMMER_OUT) return best
     const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
@@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
   }, 0)
 }
 
-function remain(x: number, y: number, item: Release, t: number) {
+function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
   const age = t - item.at
   if (age < 0 || age > LIFE) return 0
   const p = age / LIFE
   const dx = x + 0.5 - item.x - 0.5
   const dy = y * 2 + 1 - item.y * 2 - 1
   const dist = Math.hypot(dx, dy)
-  const r = SPAN * (1 - (1 - p) ** EXPAND)
+  const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
   if (dist > r) return 1
   return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
 }
 
-function wave(x: number, y: number, frame: Frame, live: boolean) {
+function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
   return frame.list.reduce((sum, item) => {
     const age = frame.t - item.at
     if (age < 0 || age > LIFE) return sum
@@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
     const dx = x + 0.5 - item.x
     const dy = y * 2 + 1 - item.y
     const dist = Math.hypot(dx, dy)
-    const r = SPAN * (1 - (1 - p) ** EXPAND)
+    const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
     const fade = (1 - p) ** 1.32
     const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
     const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
@@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
   }, 0)
 }
 
-function field(x: number, y: number, frame: Frame) {
+function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
   const held = frame.hold
   const rest = frame.release
   const item = held ?? rest
@@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
     Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
     Math.exp(-(dist * dist) / 0.15) *
     lerp(0.08, 0.42, body)
-  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
   return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
 }
 
-function pick(x: number, y: number, frame: Frame) {
+function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
   const held = frame.hold
   const rest = frame.release
   const item = held ?? rest
@@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
   const dx = x + 0.5 - item.x - 0.5
   const dy = y * 2 + 1 - item.y * 2 - 1
   const dist = Math.hypot(dx, dy)
-  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
   return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
 }
 
-function select(x: number, y: number) {
-  const direct = MAP.glyph.get(key(x, y))
+function select(x: number, y: number, ctx: LogoContext) {
+  const direct = ctx.MAP.glyph.get(key(x, y))
   if (direct !== undefined) return direct
 
-  const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
+  const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
     (item): item is number => item !== undefined,
   )
   return near
 }
 
-function trace(x: number, y: number, frame: Frame) {
+function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
   const held = frame.hold
   const rest = frame.release
   const item = held ?? rest
   if (!item || item.glyph === undefined) return 0
-  const step = MAP.trace.get(key(x, y))
+  const step = ctx.MAP.trace.get(key(x, y))
   if (!step || step.glyph !== item.glyph || step.l < 2) return 0
   const age = frame.t - item.at
   const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
@@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
   const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
   const tail = (head - TAIL + step.l) % step.l
   const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
-  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
   const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
   const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
   const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
   return (core + glow + trail) * appear * fade
 }
 
-function bloom(x: number, y: number, frame: Frame) {
+function idle(
+  x: number,
+  pixelY: number,
+  frame: Frame,
+  ctx: LogoContext,
+  state: IdleState,
+): { glow: number; peak: number; primary: number } {
+  const cfg = state.cfg
+  const dx = x + 0.5 - cfg.originX
+  const dy = pixelY - cfg.originY
+  const dist = Math.hypot(dx, dy)
+  const angle = Math.atan2(dy, dx)
+  const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
+  const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
+  const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
+  const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
+  const traveled = dist + jitter
+  let glow = 0
+  let peak = 0
+  let halo = 0
+  let primary = 0
+  let ambient = 0
+  for (const active of state.active) {
+    const head = active.head
+    const eased = active.eased
+    const delta = traveled - head
+    // Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
+    // so adjacent pixels have smaller brightness deltas
+    const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
+    const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
+    const tailRange = cfg.tail * 2.6
+    const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
+    const haloDelta = delta + cfg.haloOffset
+    const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
+    glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
+    peak += core * cfg.coreAmp * eased
+    halo += haloBand * cfg.haloAmp * eased
+    // Primary-tinted fringe follows the halo (which trails behind the core) and the tail
+    primary += (haloBand + tail * 0.6) * eased
+    ambient += active.ambient
+  }
+  ambient /= state.rings
+  return {
+    glow: glow / state.rings,
+    peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
+    primary: (primary / state.rings) * cfg.primaryMix,
+  }
+}
+
+function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
   const item = frame.glow
   if (!item) return 0
-  const glyph = MAP.glyph.get(key(x, y))
+  const glyph = ctx.MAP.glyph.get(key(x, y))
   if (glyph !== item.glyph) return 0
   const age = frame.t - item.at
   if (age < 0 || age > GLOW_OUT) return 0
   const p = age / GLOW_OUT
   const flash = (1 - p) ** 2
-  const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
-  const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
+  const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
+  const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
   const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
   return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
 }
 
-export function Logo() {
+type IdleState = {
+  cfg: ShimmerConfig
+  reach: number
+  rings: number
+  active: Array<{
+    head: number
+    eased: number
+    ambient: number
+  }>
+}
+
+function buildIdleState(t: number, ctx: LogoContext): IdleState {
+  const cfg = shimmerConfig
+  const w = ctx.FULL[0]?.length ?? 1
+  const h = ctx.FULL.length * 2
+  const corners: [number, number][] = [
+    [0, 0],
+    [w, 0],
+    [0, h],
+    [w, h],
+  ]
+  let maxCorner = 0
+  for (const [cx, cy] of corners) {
+    const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
+    if (d > maxCorner) maxCorner = d
+  }
+  const reach = maxCorner + cfg.tail * 2
+  const rings = Math.max(1, Math.floor(cfg.rings))
+  const active = [] as IdleState["active"]
+  for (let i = 0; i < rings; i++) {
+    const offset = i / rings
+    const cyclePhase = (t / cfg.period + offset) % 1
+    if (cyclePhase >= cfg.sweepFraction) continue
+    const phase = cyclePhase / cfg.sweepFraction
+    const envelope = Math.sin(phase * Math.PI)
+    const eased = envelope * envelope * (3 - 2 * envelope)
+    const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
+    active.push({
+      head: phase * reach,
+      eased,
+      ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
+    })
+  }
+  return { cfg, reach, rings, active }
+}
+
+export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
+  const ctx = props.shape ? build(props.shape) : DEFAULT
   const { theme } = useTheme()
   const [rings, setRings] = createSignal<Ring[]>([])
   const [hold, setHold] = createSignal<Hold>()
@@ -430,6 +592,7 @@ export function Logo() {
     }
     if (!live) setRelease(undefined)
     if (live || hold() || release() || glow()) return
+    if (props.idle) return
     stop()
   }
 
@@ -438,8 +601,20 @@ export function Logo() {
     timer = setInterval(tick, 16)
   }
 
+  onCleanup(() => {
+    stop()
+    hum = false
+    Sound.dispose()
+  })
+
+  onMount(() => {
+    if (!props.idle) return
+    setNow(performance.now())
+    start()
+  })
+
   const hit = (x: number, y: number) => {
-    const char = FULL[y]?.[x]
+    const char = ctx.FULL[y]?.[x]
     return char !== undefined && char !== " "
   }
 
@@ -448,7 +623,7 @@ export function Logo() {
     if (last) burst(last.x, last.y)
     setNow(t)
     if (!last) setRelease(undefined)
-    setHold({ x, y, at: t, glyph: select(x, y) })
+    setHold({ x, y, at: t, glyph: select(x, y, ctx) })
     hum = false
     start()
   }
@@ -508,6 +683,8 @@ export function Logo() {
     }
   })
 
+  const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
+
   const renderLine = (
     line: string,
     y: number,
@@ -516,24 +693,64 @@ export function Logo() {
     off: number,
     frame: Frame,
     dusk: Frame,
+    state: IdleState | undefined,
   ): JSX.Element[] => {
     const shadow = tint(theme.background, ink, 0.25)
     const attrs = bold ? TextAttributes.BOLD : undefined
 
     return Array.from(line).map((char, i) => {
-      const h = field(off + i, y, frame)
-      const n = wave(off + i, y, frame, lit(char)) + h
-      const s = wave(off + i, y, dusk, false) + h
-      const p = lit(char) ? pick(off + i, y, frame) : 0
-      const e = lit(char) ? trace(off + i, y, frame) : 0
-      const b = lit(char) ? bloom(off + i, y, frame) : 0
-      const q = shimmer(off + i, y, frame)
+      if (char === " ") {
+        return (
+          <text fg={ink} attributes={attrs} selectable={false}>
+            {char}
+          </text>
+        )
+      }
+
+      const h = field(off + i, y, frame, ctx)
+      const charLit = lit(char)
+      // Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
+      const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
+      const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
+      const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
+      const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
+      const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
+      const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
+      // Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
+      // while the bright core stays pure white
+      const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
+      const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
+      const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
+      const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
+      // For the non-peak-aware brightness channels, use the average of top/bot
+      const pulse = {
+        glow: (pulseTop.glow + pulseBot.glow) / 2,
+        peak: (pulseTop.peak + pulseBot.peak) / 2,
+        primary: (pulseTop.primary + pulseBot.primary) / 2,
+      }
+      const peakMix = charLit ? Math.min(1, pulse.peak) : 0
+      const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
+      const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
+      const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
+      const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
+      const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
+      const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
+      const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
+      const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
+      const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
+      const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
+      const n = wave(off + i, y, frame, charLit, ctx) + h
+      const s = wave(off + i, y, dusk, false, ctx) + h
+      const p = charLit ? pick(off + i, y, frame, ctx) : 0
+      const e = charLit ? trace(off + i, y, frame, ctx) : 0
+      const b = charLit ? bloom(off + i, y, frame, ctx) : 0
+      const q = shimmer(off + i, y, frame, ctx)
 
       if (char === "_") {
         return (
           <text
-            fg={shade(ink, theme, s * 0.08)}
-            bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
+            fg={shade(inkTinted, theme, s * 0.08)}
+            bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
             attributes={attrs}
             selectable={false}
           >
@@ -545,8 +762,8 @@ export function Logo() {
       if (char === "^") {
         return (
           <text
-            fg={shade(ink, theme, n + p + e + b)}
-            bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
+            fg={shade(inkTop, theme, n + p + e + b)}
+            bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
             attributes={attrs}
             selectable={false}
           >
@@ -557,34 +774,60 @@ export function Logo() {
 
       if (char === "~") {
         return (
-          <text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
+          <text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
           </text>
         )
       }
 
-      if (char === " ") {
+      if (char === ",") {
         return (
-          <text fg={ink} attributes={attrs} selectable={false}>
-            {char}
+          <text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
+            ▄
+          </text>
+        )
+      }
+
+      // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
+      if (char === "█") {
+        return (
+          <text
+            fg={shade(inkTop, theme, n + p + e + b)}
+            bg={shade(inkBot, theme, n + p + e + b)}
+            attributes={attrs}
+            selectable={false}
+          >
+            ▀
+          </text>
+        )
+      }
+
+      // ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
+      if (char === "▀") {
+        return (
+          <text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+            ▀
+          </text>
+        )
+      }
+
+      // ▄ bottom-half-lit: fg uses bottom-pixel sample
+      if (char === "▄") {
+        return (
+          <text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+            ▄
           </text>
         )
       }
 
       return (
-        <text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+        <text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
           {char}
         </text>
       )
     })
   }
 
-  onCleanup(() => {
-    stop()
-    hum = false
-    Sound.dispose()
-  })
-
   const mouse = (evt: MouseEvent) => {
     if (!box) return
     if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
@@ -613,17 +856,28 @@ export function Logo() {
         position="absolute"
         top={0}
         left={0}
-        width={FULL[0]?.length ?? 0}
-        height={FULL.length}
+        width={ctx.FULL[0]?.length ?? 0}
+        height={ctx.FULL.length}
         zIndex={1}
         onMouse={mouse}
       />
-      <For each={logo.left}>
+      <For each={ctx.shape.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
-            <box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
             <box flexDirection="row">
-              {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
+              {renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
+            </box>
+            <box flexDirection="row">
+              {renderLine(
+                ctx.shape.right[index()],
+                index(),
+                props.ink ?? theme.text,
+                true,
+                ctx.LEFT + GAP,
+                frame(),
+                dusk(),
+                idleState(),
+              )}
             </box>
           </box>
         )}
@@ -631,3 +885,9 @@ export function Logo() {
     </box>
   )
 }
+
+export function GoLogo() {
+  const { theme } = useTheme()
+  const base = tint(theme.background, theme.text, 0.62)
+  return <Logo shape={go} ink={base} idle />
+}

+ 109 - 28
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -1,18 +1,19 @@
-import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
+import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
 import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
 import "opentui-spinner/solid"
 import path from "path"
 import { fileURLToPath } from "url"
 import { Filesystem } from "@/util"
 import { useLocal } from "@tui/context/local"
-import { useTheme } from "@tui/context/theme"
+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"
-import { createStore, produce } from "solid-js/store"
+import { createStore, produce, unwrap } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
 import { usePromptHistory, type PromptInfo } from "./history"
 import { assign } from "./part"
@@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
 import { DialogAlert } from "../../ui/dialog-alert"
 import { useToast } from "../../ui/toast"
 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 = {
@@ -75,6 +79,12 @@ function randomIndex(count: number) {
   return Math.floor(Math.random() * count)
 }
 
+function fadeColor(color: RGBA, alpha: number) {
+  return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
+}
+
+let stashed: { prompt: PromptInfo; cursor: number } | undefined
+
 export function Prompt(props: PromptProps) {
   let input: TextareaRenderable
   let anchor: BoxRenderable
@@ -85,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()
@@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
   const renderer = useRenderer()
   const { theme, syntax } = useTheme()
   const kv = useKV()
+  const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
   const list = createMemo(() => props.placeholders?.normal ?? [])
   const shell = createMemo(() => props.placeholders?.shell ?? [])
   const [auto, setAuto] = createSignal<AutocompleteRef>()
@@ -233,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()
         },
       },
@@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
     },
   }
 
+  onMount(() => {
+    const saved = stashed
+    stashed = undefined
+    if (store.prompt.input) return
+    if (saved && saved.prompt.input) {
+      input.setText(saved.prompt.input)
+      setStore("prompt", saved.prompt)
+      restoreExtmarksFromParts(saved.prompt.parts)
+      input.cursorOffset = saved.cursor
+    }
+  })
+
   onCleanup(() => {
+    if (store.prompt.input) {
+      stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
+    }
     props.ref?.(undefined)
   })
 
   createEffect(() => {
     if (!input || input.isDestroyed) return
     if (props.visible === false || dialog.stack.length > 0) {
-      input.blur()
+      if (input.focused) input.blur()
       return
     }
 
     // Slot/plugin updates can remount the background prompt while a dialog is open.
     // Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
-    input.focus()
+    if (!input.focused) input.focus()
   })
 
   createEffect(() => {
     if (!input || input.isDestroyed) return
+    const capture =
+      store.mode === "normal"
+        ? auto()?.visible
+          ? (["escape", "navigate", "submit", "tab"] as const)
+          : (["tab"] as const)
+        : undefined
     input.traits = {
-      capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
+      capture,
       suspend: !!props.disabled || store.mode === "shell",
       status: store.mode === "shell" ? "SHELL" : undefined,
     }
@@ -599,27 +634,53 @@ 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
     if (sessionID == null) {
-      const res = await sdk.client.session.create({
-        workspaceID: props.workspaceID,
-      })
+      const res = await sdk.client.session.create({ workspace: props.workspaceID })
 
       if (res.error) {
         console.log("Creating a session failed:", res.error)
@@ -629,7 +690,7 @@ export function Prompt(props: PromptProps) {
           variant: "error",
         })
 
-        return
+        return true
       }
 
       sessionID = res.data.id
@@ -743,6 +804,7 @@ export function Prompt(props: PromptProps) {
         })
       }, 50)
     input.clear()
+    return true
   }
   const exit = useExit()
 
@@ -843,6 +905,14 @@ export function Prompt(props: PromptProps) {
     return !!current
   })
 
+  const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
+  const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
+  const variantMetaAlpha = createFadeIn(
+    () => !!local.agent.current() && store.mode === "normal" && showVariant(),
+    animationsEnabled,
+  )
+  const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
+
   const placeholderText = createMemo(() => {
     if (props.showPlaceholder === false) return undefined
     if (store.mode === "shell") {
@@ -903,7 +973,7 @@ export function Prompt(props: PromptProps) {
       <box ref={(r) => (anchor = r)} visible={props.visible !== false}>
         <box
           border={["left"]}
-          borderColor={highlight()}
+          borderColor={borderHighlight()}
           customBorderChars={{
             ...SplitBorder.customBorderChars,
             bottomLeft: "╹",
@@ -1033,6 +1103,10 @@ export function Prompt(props: PromptProps) {
                   return
                 }
 
+                // Once we cross an async boundary below, the terminal may perform its
+                // default paste unless we suppress it first and handle insertion ourselves.
+                event.preventDefault()
+
                 const filepath = iife(() => {
                   const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
                   if (raw.startsWith("file://")) {
@@ -1050,7 +1124,6 @@ export function Prompt(props: PromptProps) {
                     const filename = path.basename(filepath)
                     // Handle SVG as raw text content, not as base64 image
                     if (mime === "image/svg+xml") {
-                      event.preventDefault()
                       const content = await Filesystem.readText(filepath).catch(() => {})
                       if (content) {
                         pasteText(content, `[SVG: ${filename ?? "image"}]`)
@@ -1058,7 +1131,6 @@ export function Prompt(props: PromptProps) {
                       }
                     }
                     if (mime.startsWith("image/") || mime === "application/pdf") {
-                      event.preventDefault()
                       const content = await Filesystem.readArrayBuffer(filepath)
                         .then((buffer) => Buffer.from(buffer).toString("base64"))
                         .catch(() => {})
@@ -1080,11 +1152,12 @@ export function Prompt(props: PromptProps) {
                   (lineCount >= 3 || pastedContent.length > 150) &&
                   !sync.data.config.experimental?.disable_paste_summary
                 ) {
-                  event.preventDefault()
                   pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
                   return
                 }
 
+                input.insertText(normalizedText)
+
                 // Force layout update and render for the pasted content
                 setTimeout(() => {
                   // setTimeout is a workaround and needs to be addressed properly
@@ -1115,17 +1188,25 @@ export function Prompt(props: PromptProps) {
                 <Show when={local.agent.current()} fallback={<box height={1} />}>
                   {(agent) => (
                     <>
-                      <text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
+                      <text fg={fadeColor(highlight(), agentMetaAlpha())}>
+                        {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
+                      </text>
                       <Show when={store.mode === "normal"}>
                         <box flexDirection="row" gap={1}>
-                          <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
+                          <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
+                          <text
+                            flexShrink={0}
+                            fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
+                          >
                             {local.model.parsed().model}
                           </text>
-                          <text fg={theme.textMuted}>{currentProviderLabel()}</text>
+                          <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
                           <Show when={showVariant()}>
-                            <text fg={theme.textMuted}>·</text>
+                            <text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
                             <text>
-                              <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
+                              <span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
+                                {local.model.variant.current()}
+                              </span>
                             </text>
                           </Show>
                         </box>
@@ -1145,7 +1226,7 @@ export function Prompt(props: PromptProps) {
         <box
           height={1}
           border={["left"]}
-          borderColor={highlight()}
+          borderColor={borderHighlight()}
           customBorderChars={{
             ...EmptyBorder,
             vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",

+ 4 - 3
packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts

@@ -26,7 +26,6 @@ const TuiLegacy = z
 interface MigrateInput {
   cwd: string
   directories: string[]
-  custom?: string
 }
 
 /**
@@ -133,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
 }
 
 async function opencodeFiles(input: { directories: string[]; cwd: string }) {
-  const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
-  const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
+  const files = [
+    ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
+    ...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
+  ]
   for (const dir of unique(input.directories)) {
     files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
   }

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

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

@@ -1,6 +1,9 @@
+export * as TuiConfig from "./tui"
+
 import z from "zod"
 import { mergeDeep, unique } from "remeda"
 import { Context, Effect, Fiber, Layer } from "effect"
+import { ConfigParse } from "@/config/parse"
 import * as ConfigPaths from "@/config/paths"
 import { migrateTuiConfig } from "./tui-migrate"
 import { TuiInfo } from "./tui-schema"
@@ -8,201 +11,209 @@ import { Flag } from "@/flag/flag"
 import { isRecord } from "@/util/record"
 import { Global } from "@/global"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Npm } from "@opencode-ai/shared/npm"
 import { CurrentWorkingDirectory } from "./cwd"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigKeybinds } from "@/config/keybinds"
 import { InstallationLocal, InstallationVersion } from "@/installation/version"
-import { makeRuntime } from "@/cli/effect/runtime"
+import { makeRuntime } from "@/effect/runtime"
 import { Filesystem, Log } from "@/util"
+import { ConfigVariable } from "@/config/variable"
+import { Npm } from "@/npm"
 
-export namespace TuiConfig {
-  const log = Log.create({ service: "tui.config" })
+const log = Log.create({ service: "tui.config" })
 
-  export const Info = TuiInfo
+export const Info = TuiInfo
 
-  type Acc = {
-    result: Info
-  }
+type Acc = {
+  result: Info
+}
 
-  type State = {
-    config: Info
-    deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
-  }
+type State = {
+  config: Info
+  deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
+}
 
-  export type Info = z.output<typeof Info> & {
-    // Internal resolved plugin list used by runtime loading.
-    plugin_origins?: ConfigPlugin.Origin[]
-  }
+export type Info = z.output<typeof Info> & {
+  // Internal resolved plugin list used by runtime loading.
+  plugin_origins?: ConfigPlugin.Origin[]
+}
 
-  export interface Interface {
-    readonly get: () => Effect.Effect<Info>
-    readonly waitForDependencies: () => Effect.Effect<void>
-  }
+export interface Interface {
+  readonly get: () => Effect.Effect<Info>
+  readonly waitForDependencies: () => Effect.Effect<void>
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
 
-  function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
-    if (Filesystem.contains(ctx.directory, file)) return "local"
-    // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
-    return "global"
-  }
+function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
+  if (Filesystem.contains(ctx.directory, file)) return "local"
+  // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
+  return "global"
+}
 
-  function customPath() {
-    return Flag.OPENCODE_TUI_CONFIG
+function normalize(raw: Record<string, unknown>) {
+  const data = { ...raw }
+  if (!("tui" in data)) return data
+  if (!isRecord(data.tui)) {
+    delete data.tui
+    return data
   }
 
-  function normalize(raw: Record<string, unknown>) {
-    const data = { ...raw }
-    if (!("tui" in data)) return data
-    if (!isRecord(data.tui)) {
-      delete data.tui
-      return data
-    }
-
-    const tui = data.tui
-    delete data.tui
-    return {
-      ...tui,
-      ...data,
-    }
+  const tui = data.tui
+  delete data.tui
+  return {
+    ...tui,
+    ...data,
   }
+}
 
-  async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
-    const data = await loadFile(file)
-    acc.result = mergeDeep(acc.result, data)
-    if (!data.plugin?.length) return
-
-    const scope = pluginScope(file, ctx)
-    const plugins = ConfigPlugin.deduplicatePluginOrigins([
-      ...(acc.result.plugin_origins ?? []),
-      ...data.plugin.map((spec) => ({ spec, scope, source: file })),
-    ])
-    acc.result.plugin = plugins.map((item) => item.spec)
-    acc.result.plugin_origins = plugins
+async function resolvePlugins(config: Info, configFilepath: string) {
+  if (!config.plugin) return config
+  for (let i = 0; i < config.plugin.length; i++) {
+    config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
   }
+  return config
+}
 
-  async function loadState(ctx: { directory: string }) {
-    let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
-    const directories = await ConfigPaths.directories(ctx.directory)
-    const custom = customPath()
-    await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
-    // Re-compute after migration since migrateTuiConfig may have created new tui.json files
-    projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
+  const data = await loadFile(file)
+  acc.result = mergeDeep(acc.result, data)
+  if (!data.plugin?.length) return
+
+  const scope = pluginScope(file, ctx)
+  const plugins = ConfigPlugin.deduplicatePluginOrigins([
+    ...(acc.result.plugin_origins ?? []),
+    ...data.plugin.map((spec) => ({ spec, scope, source: file })),
+  ])
+  acc.result.plugin = plugins.map((item) => item.spec)
+  acc.result.plugin_origins = plugins
+}
 
-    const acc: Acc = {
-      result: {},
-    }
+const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
+  // Every config dir we may read from: global config dir, any `.opencode`
+  // folders between cwd and home, and OPENCODE_CONFIG_DIR.
+  const directories = yield* ConfigPaths.directories(ctx.directory)
+  yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
 
-    for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
-      await mergeFile(acc, file, ctx)
-    }
+  const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
 
-    if (custom) {
-      await mergeFile(acc, custom, ctx)
-      log.debug("loaded custom tui config", { path: custom })
-    }
+  const acc: Acc = {
+    result: {},
+  }
 
-    for (const file of projectFiles) {
-      await mergeFile(acc, file, ctx)
-    }
+  // 1. Global tui config (lowest precedence).
+  for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+    yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
+  }
 
-    const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
+  // 2. Explicit OPENCODE_TUI_CONFIG override, if set.
+  if (Flag.OPENCODE_TUI_CONFIG) {
+    const configFile = Flag.OPENCODE_TUI_CONFIG
+    yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
+    log.debug("loaded custom tui config", { path: configFile })
+  }
 
-    for (const dir of dirs) {
-      if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
-      for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
-        await mergeFile(acc, file, ctx)
-      }
-    }
+  // 3. Project tui files, applied root-first so the closest file wins.
+  for (const file of projectFiles) {
+    yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
+  }
 
-    const keybinds = { ...(acc.result.keybinds ?? {}) }
-    if (process.platform === "win32") {
-      // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
-      keybinds.terminal_suspend = "none"
-      keybinds.input_undo ??= unique([
-        "ctrl+z",
-        ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
-      ]).join(",")
-    }
-    acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
+  // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
+  // walking up the tree. Also returned below so callers can install plugin
+  // dependencies from each location.
+  const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
 
-    return {
-      config: acc.result,
-      dirs: acc.result.plugin?.length ? dirs : [],
+  for (const dir of dirs) {
+    if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+    for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+      yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
     }
   }
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const directory = yield* CurrentWorkingDirectory
-      const npm = yield* Npm.Service
-      const data = yield* Effect.promise(() => loadState({ directory }))
-      const deps = yield* Effect.forEach(
-        data.dirs,
-        (dir) =>
-          npm
-            .install(dir, {
-              add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
-            })
-            .pipe(Effect.forkScoped),
-        {
-          concurrency: "unbounded",
-        },
-      )
-
-      const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
-
-      const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
-        Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
-      )
-      return Service.of({ get, waitForDependencies })
-    }).pipe(Effect.withSpan("TuiConfig.layer")),
-  )
-
-  export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function waitForDependencies() {
-    await runPromise((svc) => svc.waitForDependencies())
+  const keybinds = { ...(acc.result.keybinds ?? {}) }
+  if (process.platform === "win32") {
+    // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
+    keybinds.terminal_suspend = "none"
+    keybinds.input_undo ??= unique([
+      "ctrl+z",
+      ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
+    ]).join(",")
   }
+  acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
 
-  export async function get() {
-    return runPromise((svc) => svc.get())
+  return {
+    config: acc.result,
+    dirs: acc.result.plugin?.length ? dirs : [],
   }
+})
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const directory = yield* CurrentWorkingDirectory
+    const npm = yield* Npm.Service
+    const data = yield* loadState({ directory })
+    const deps = yield* Effect.forEach(
+      data.dirs,
+      (dir) =>
+        npm
+          .install(dir, {
+            add: [
+              {
+                name: "@opencode-ai/plugin",
+                version: InstallationLocal ? undefined : InstallationVersion,
+              },
+            ],
+          })
+          .pipe(Effect.forkScoped),
+      {
+        concurrency: "unbounded",
+      },
+    )
+
+    const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
+
+    const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
+      Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
+    )
+    return Service.of({ get, waitForDependencies })
+  }).pipe(Effect.withSpan("TuiConfig.layer")),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export async function waitForDependencies() {
+  await runPromise((svc) => svc.waitForDependencies())
+}
 
-  async function loadFile(filepath: string): Promise<Info> {
-    const text = await ConfigPaths.readFile(filepath)
-    if (!text) return {}
-    return load(text, filepath).catch((error) => {
-      log.warn("failed to load tui config", { path: filepath, error })
-      return {}
-    })
-  }
+export async function get() {
+  return runPromise((svc) => svc.get())
+}
 
-  async function load(text: string, configFilepath: string): Promise<Info> {
-    const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
-    if (!isRecord(raw)) return {}
+async function loadFile(filepath: string): Promise<Info> {
+  const text = await ConfigPaths.readFile(filepath)
+  if (!text) return {}
+  return load(text, filepath).catch((error) => {
+    log.warn("failed to load tui config", { path: filepath, error })
+    return {}
+  })
+}
 
-    // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
-    // (mirroring the old opencode.json shape) still get their settings applied.
-    const normalized = normalize(raw)
+async function load(text: string, configFilepath: string): Promise<Info> {
+  return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
+    .then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
+    .then((data) => {
+      if (!isRecord(data)) return {}
 
-    const parsed = Info.safeParse(normalized)
-    if (!parsed.success) {
-      log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
+      // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+      // (mirroring the old opencode.json shape) still get their settings applied.
+      return ConfigParse.schema(Info, normalize(data), configFilepath)
+    })
+    .then((data) => resolvePlugins(data, configFilepath))
+    .catch((error) => {
+      log.warn("invalid tui config", { path: configFilepath, error })
       return {}
-    }
-
-    const data = parsed.data
-    if (data.plugin) {
-      for (let i = 0; i < data.plugin.length; i++) {
-        data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
-      }
-    }
-
-    return data
-  }
+    })
 }

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/kv.tsx

@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
     const [store, setStore] = createStore<Record<string, any>>()
     const filePath = path.join(Global.Path.state, "kv.json")
 
-    Filesystem.readJson(filePath)
+    Filesystem.readJson<Record<string, any>>(filePath)
       .then((x) => {
         setStore(x)
       })

+ 3 - 1
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         },
         move(direction: 1 | -1) {
           batch(() => {
-            let next = agents().findIndex((x) => x.name === agentStore.current) + direction
+            const current = this.current()
+            if (!current) return
+            let next = agents().findIndex((x) => x.name === current.name) + direction
             if (next < 0) next = agents().length - 1
             if (next >= agents().length) next = 0
             const value = agents()[next]

+ 11 - 8
packages/opencode/src/cli/cmd/tui/context/project.tsx

@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
   name: "Project",
   init: () => {
     const sdk = useSDK()
+
+    const defaultPath = {
+      home: "",
+      state: "",
+      config: "",
+      worktree: "",
+      directory: sdk.directory ?? "",
+    } satisfies Path
+
     const [store, setStore] = createStore({
       project: {
         id: undefined as string | undefined,
       },
       instance: {
-        path: {
-          home: "",
-          state: "",
-          config: "",
-          worktree: "",
-          directory: sdk.directory ?? "",
-        } satisfies Path,
+        path: defaultPath,
       },
       workspace: {
         current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
       ])
 
       batch(() => {
-        setStore("instance", "path", reconcile(path.data!))
+        setStore("instance", "path", reconcile(path.data || defaultPath))
         setStore("project", "id", project.data?.id)
       })
     }

+ 11 - 10
packages/opencode/src/cli/cmd/tui/context/route.tsx

@@ -1,16 +1,16 @@
-import { createStore } from "solid-js/store"
+import { createStore, reconcile } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import type { PromptInfo } from "../component/prompt/history"
 
 export type HomeRoute = {
   type: "home"
-  initialPrompt?: PromptInfo
+  prompt?: PromptInfo
 }
 
 export type SessionRoute = {
   type: "session"
   sessionID: string
-  initialPrompt?: PromptInfo
+  prompt?: PromptInfo
 }
 
 export type PluginRoute = {
@@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
 
 export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
   name: "Route",
-  init: () => {
+  init: (props: { initialRoute?: Route }) => {
     const [store, setStore] = createStore<Route>(
-      process.env["OPENCODE_ROUTE"]
-        ? JSON.parse(process.env["OPENCODE_ROUTE"])
-        : {
-            type: "home",
-          },
+      props.initialRoute ??
+        (process.env["OPENCODE_ROUTE"]
+          ? JSON.parse(process.env["OPENCODE_ROUTE"])
+          : {
+              type: "home",
+            }),
     )
 
     return {
@@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
         return store
       },
       navigate(route: Route) {
-        setStore(route)
+        setStore(reconcile(route))
       },
     }
   },

+ 27 - 1
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { Flag } from "@/flag/flag"
 import { batch, onCleanup, onMount } from "solid-js"
 
 export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
     let queue: GlobalEvent[] = []
     let timer: Timer | undefined
     let last = 0
+    const retryDelay = 1000
+    const maxRetryDelay = 30000
 
     const flush = () => {
       if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       const ctrl = new AbortController()
       sse = ctrl
       ;(async () => {
+        let attempt = 0
         while (true) {
           if (abort.signal.aborted || ctrl.signal.aborted) break
-          const events = await sdk.global.event({ signal: ctrl.signal })
+
+          const events = await sdk.global.event({
+            signal: ctrl.signal,
+            sseMaxRetryAttempts: 0,
+          })
+
+          if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+            // Start syncing workspaces, it's important to do this after
+            // we've started listening to events
+            await sdk.sync.start().catch(() => {})
+          }
 
           for await (const event of events.stream) {
             if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
 
           if (timer) clearTimeout(timer)
           if (queue.length > 0) flush()
+          attempt += 1
+          if (abort.signal.aborted || ctrl.signal.aborted) break
+
+          // Exponential backoff
+          const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
+          await new Promise((resolve) => setTimeout(resolve, backoff))
         }
       })().catch(() => {})
     }
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       if (props.events) {
         const unsub = await props.events.subscribe(handleEvent)
         onCleanup(unsub)
+
+        if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+          // Start syncing workspaces, it's important to do this after
+          // we've started listening to events
+          await sdk.sync.start().catch(() => {})
+        }
       } else {
         startSSE()
       }

+ 31 - 19
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
 import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
 import { useArgs } from "./args"
-import { batch, createEffect, on } from "solid-js"
+import { batch, onMount } from "solid-js"
 import { Log } from "@/util"
 import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
 
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const project = useProject()
     const sdk = useSDK()
 
+    const fullSyncedSessions = new Set<string>()
+    let syncedWorkspace = project.workspace.current()
+
     event.subscribe((event) => {
       switch (event.type) {
         case "server.instance.disposed":
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const exit = useExit()
     const args = useArgs()
 
-    async function bootstrap() {
-      console.log("bootstrapping")
+    async function bootstrap(input: { fatal?: boolean } = {}) {
+      const fatal = input.fatal ?? true
       const workspace = project.workspace.current()
+      if (workspace !== syncedWorkspace) {
+        fullSyncedSessions.clear()
+        syncedWorkspace = workspace
+      }
       const start = Date.now() - 30 * 24 * 60 * 60 * 1000
       const sessionListPromise = sdk.client.session
         .list({ start: start })
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             name: e instanceof Error ? e.name : undefined,
             stack: e instanceof Error ? e.stack : undefined,
           })
-          await exit(e)
+          if (fatal) {
+            await exit(e)
+          } else {
+            throw e
+          }
         })
     }
 
-    const fullSyncedSessions = new Set<string>()
-    createEffect(
-      on(
-        () => project.workspace.current(),
-        () => {
-          fullSyncedSessions.clear()
-          void bootstrap()
-        },
-      ),
-    )
+    onMount(() => {
+      void bootstrap()
+    })
 
     const result = {
       data: store,
@@ -463,6 +467,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         return store.status
       },
       get ready() {
+        return true
+        if (process.env.OPENCODE_FAST_BOOT) return true
         return store.status !== "loading"
       },
       get path() {
@@ -474,6 +480,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           if (match.found) return store.session[match.index]
           return undefined
         },
+        async refresh() {
+          const start = Date.now() - 30 * 24 * 60 * 60 * 1000
+          const list = await sdk.client.session
+            .list({ start })
+            .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
+          setStore("session", reconcile(list))
+        },
         status(sessionID: string) {
           const session = result.session.get(sessionID)
           if (!session) return "idle"
@@ -486,12 +499,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         },
         async sync(sessionID: string) {
           if (fullSyncedSessions.has(sessionID)) return
-          const workspace = project.workspace.current()
           const [session, messages, todo, diff] = await Promise.all([
-            sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
-            sdk.client.session.messages({ sessionID, limit: 100, workspace }),
-            sdk.client.session.todo({ sessionID, workspace }),
-            sdk.client.session.diff({ sessionID, workspace }),
+            sdk.client.session.get({ sessionID }, { throwOnError: true }),
+            sdk.client.session.messages({ sessionID, limit: 100 }),
+            sdk.client.session.todo({ sessionID }),
+            sdk.client.session.diff({ sessionID }),
           ])
           setStore(
             produce((draft) => {

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -397,7 +397,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       if (store.lock) return
       apply(mode)
     }
-    renderer.on(CliRenderEvents.THEME_MODE, handle)
+    // renderer.on(CliRenderEvents.THEME_MODE, handle)
 
     const refresh = () => {
       renderer.clearPaletteCache()

+ 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 "@opencode-ai/shared/npm"
+import { Npm } from "@/npm"
 import { Observability } from "@/effect/observability"
 
 export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))

+ 1 - 1
packages/opencode/src/cli/cmd/tui/plugin/api.tsx

@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
       name: "session",
       params: {
         sessionID: route.data.sessionID,
-        initialPrompt: route.data.initialPrompt,
+        prompt: route.data.prompt,
       },
     }
   }

+ 102 - 90
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -1,4 +1,4 @@
-// import "@opentui/solid/runtime-plugin-support"
+import "@opentui/solid/runtime-plugin-support"
 import {
   type TuiDispose,
   type TuiPlugin,
@@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
 import { Log } from "@/util"
 import { errorData, errorMessage } from "@/util/error"
 import { isRecord } from "@/util/record"
+import { Instance } from "@/project/instance"
 import {
   readPackageThemes,
   readPluginId,
@@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
     state.pending.delete(spec)
     return true
   }
-  const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies())
+  const ready = await Instance.provide({
+    directory: state.directory,
+    fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
+  }).catch((error) => {
+    fail("failed to add tui plugin", { path: next, error })
+    return [] as PluginLoad[]
+  })
   if (!ready.length) {
     return false
   }
@@ -911,108 +918,113 @@ async function installPluginBySpec(
   }
 }
 
-export namespace TuiPluginRuntime {
-  let dir = ""
-  let loaded: Promise<void> | undefined
-  let runtime: RuntimeState | undefined
-  export const Slot = View
+let dir = ""
+let loaded: Promise<void> | undefined
+let runtime: RuntimeState | undefined
+export const Slot = View
 
-  export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
-    const cwd = process.cwd()
-    if (loaded) {
-      if (dir !== cwd) {
-        throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
-      }
-      return loaded
+export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
+  const cwd = process.cwd()
+  if (loaded) {
+    if (dir !== cwd) {
+      throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
     }
-
-    dir = cwd
-    loaded = load(input)
     return loaded
   }
 
-  export function list() {
-    if (!runtime) return []
-    return listPluginStatus(runtime)
-  }
+  dir = cwd
+  loaded = load(input)
+  return loaded
+}
 
-  export async function activatePlugin(id: string) {
-    return activatePluginById(runtime, id, true)
-  }
+export function list() {
+  if (!runtime) return []
+  return listPluginStatus(runtime)
+}
 
-  export async function deactivatePlugin(id: string) {
-    return deactivatePluginById(runtime, id, true)
-  }
+export async function activatePlugin(id: string) {
+  return activatePluginById(runtime, id, true)
+}
 
-  export async function addPlugin(spec: string) {
-    return addPluginBySpec(runtime, spec)
-  }
+export async function deactivatePlugin(id: string) {
+  return deactivatePluginById(runtime, id, true)
+}
 
-  export async function installPlugin(spec: string, options?: { global?: boolean }) {
-    return installPluginBySpec(runtime, spec, options?.global)
-  }
+export async function addPlugin(spec: string) {
+  return addPluginBySpec(runtime, spec)
+}
 
-  export async function dispose() {
-    const task = loaded
-    loaded = undefined
-    dir = ""
-    if (task) await task
-    const state = runtime
-    runtime = undefined
-    if (!state) return
-    const queue = [...state.plugins].reverse()
-    for (const plugin of queue) {
-      await deactivatePluginEntry(state, plugin, false)
-    }
+export async function installPlugin(spec: string, options?: { global?: boolean }) {
+  return installPluginBySpec(runtime, spec, options?.global)
+}
+
+export async function dispose() {
+  const task = loaded
+  loaded = undefined
+  dir = ""
+  if (task) await task
+  const state = runtime
+  runtime = undefined
+  if (!state) return
+  const queue = [...state.plugins].reverse()
+  for (const plugin of queue) {
+    await deactivatePluginEntry(state, plugin, false)
   }
+}
 
-  async function load(input: { api: Api; config: TuiConfig.Info }) {
-    const { api, config } = input
-    const cwd = process.cwd()
-    const slots = setupSlots(api)
-    const next: RuntimeState = {
+async function load(input: { api: Api; config: TuiConfig.Info }) {
+  const { api, config } = input
+  const cwd = process.cwd()
+  const slots = setupSlots(api)
+  const next: RuntimeState = {
+    directory: cwd,
+    api,
+    slots,
+    plugins: [],
+    plugins_by_id: new Map(),
+    pending: new Map(),
+  }
+  runtime = next
+  try {
+    await Instance.provide({
       directory: cwd,
-      api,
-      slots,
-      plugins: [],
-      plugins_by_id: new Map(),
-      pending: new Map(),
-    }
-    runtime = next
-    try {
-      const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
-      if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
-        log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
-      }
+      fn: async () => {
+        const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
+        if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
+          log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
+        }
 
-      for (const item of INTERNAL_TUI_PLUGINS) {
-        log.info("loading internal tui plugin", { id: item.id })
-        const entry = loadInternalPlugin(item)
-        const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
-        addPluginEntry(next, {
-          id: entry.id,
-          load: entry,
-          meta,
-          themes: {},
-          plugin: entry.module.tui,
-          enabled: true,
-        })
-      }
+        for (const item of INTERNAL_TUI_PLUGINS) {
+          log.info("loading internal tui plugin", { id: item.id })
+          const entry = loadInternalPlugin(item)
+          const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
+          addPluginEntry(next, {
+            id: entry.id,
+            load: entry,
+            meta,
+            themes: {},
+            plugin: entry.module.tui,
+            enabled: true,
+          })
+        }
 
-      const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
-      await addExternalPluginEntries(next, ready)
-
-      applyInitialPluginEnabledState(next, config)
-      for (const plugin of next.plugins) {
-        if (!plugin.enabled) continue
-        // Keep plugin execution sequential for deterministic side effects:
-        // command registration order affects keybind/command precedence,
-        // route registration is last-wins when ids collide,
-        // and hook chains rely on stable plugin ordering.
-        await activatePluginEntry(next, plugin, false)
-      }
-    } catch (error) {
-      fail("failed to load tui plugins", { directory: cwd, error })
-    }
+        const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
+        await addExternalPluginEntries(next, ready)
+
+        applyInitialPluginEnabledState(next, config)
+        for (const plugin of next.plugins) {
+          if (!plugin.enabled) continue
+          // Keep plugin execution sequential for deterministic side effects:
+          // command registration order affects keybind/command precedence,
+          // route registration is last-wins when ids collide,
+          // and hook chains rely on stable plugin ordering.
+          await activatePluginEntry(next, plugin, false)
+        }
+      },
+    })
+  } catch (error) {
+    fail("failed to load tui plugins", { directory: cwd, error })
   }
 }
+
+export * as TuiPluginRuntime from "./runtime"

+ 2 - 3
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
 import { useLocal } from "../context/local"
 import { TuiPluginRuntime } from "../plugin"
 
-// TODO: what is the best way to do this?
 let once = false
 const placeholder = {
   normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
@@ -31,8 +30,8 @@ export function Home() {
     setRef(r)
     promptRef.set(r)
     if (once || !r) return
-    if (route.initialPrompt) {
-      r.set(route.initialPrompt)
+    if (route.prompt) {
+      r.set(route.prompt)
       once = true
       return
     }

+ 2 - 2
packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx

@@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
             messageID: message.id,
           })
           const parts = sync.data.part[message.id] ?? []
-          const initialPrompt = parts.reduce(
+          const prompt = parts.reduce(
             (agg, part) => {
               if (part.type === "text") {
                 if (!part.synthetic) agg.input += part.text
@@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
           route.navigate({
             sessionID: forked.data!.id,
             type: "session",
-            initialPrompt,
+            prompt,
           })
           dialog.clear()
         },

+ 14 - 16
packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx

@@ -81,25 +81,23 @@ export function DialogMessage(props: {
               sessionID: props.sessionID,
               messageID: props.messageID,
             })
-            const initialPrompt = (() => {
-              const msg = message()
-              if (!msg) return undefined
-              const parts = sync.data.part[msg.id]
-              return parts.reduce(
-                (agg, part) => {
-                  if (part.type === "text") {
-                    if (!part.synthetic) agg.input += part.text
-                  }
-                  if (part.type === "file") agg.parts.push(part)
-                  return agg
-                },
-                { input: "", parts: [] as PromptInfo["parts"] },
-              )
-            })()
+            const msg = message()
+            const prompt = msg
+              ? sync.data.part[msg.id].reduce(
+                  (agg, part) => {
+                    if (part.type === "text") {
+                      if (!part.synthetic) agg.input += part.text
+                    }
+                    if (part.type === "file") agg.parts.push(part)
+                    return agg
+                  },
+                  { input: "", parts: [] as PromptInfo["parts"] },
+                )
+              : undefined
             route.navigate({
               sessionID: result.data!.id,
               type: "session",
-              initialPrompt,
+              prompt,
             })
             dialog.clear()
           },

+ 49 - 62
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -44,6 +44,8 @@ import type { GrepTool } from "@/tool/grep"
 import type { EditTool } from "@/tool/edit"
 import type { ApplyPatchTool } from "@/tool/apply_patch"
 import type { WebFetchTool } from "@/tool/webfetch"
+import type { CodeSearchTool } from "@/tool/codesearch"
+import type { WebSearchTool } from "@/tool/websearch"
 import type { TaskTool } from "@/tool/task"
 import type { QuestionTool } from "@/tool/question"
 import type { SkillTool } from "@/tool/skill"
@@ -52,7 +54,6 @@ import { useSDK } from "@tui/context/sdk"
 import { useCommandDialog } from "@tui/component/dialog-command"
 import type { DialogContext } from "@tui/ui/dialog"
 import { useKeybind } from "@tui/context/keybind"
-import { parsePatch } from "diff"
 import { useDialog } from "../../ui/dialog"
 import { TodoItem } from "../../component/todo-item"
 import { DialogMessage } from "./dialog-message"
@@ -86,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
 import { TuiPluginRuntime } from "../../plugin"
 import { DialogGoUpsell } from "../../component/dialog-go-upsell"
 import { SessionRetry } from "@/session/retry"
+import { getRevertDiffFiles } from "../../util/revert-diff"
 
 addDefaultParsers(parsers.parsers)
 
@@ -179,27 +181,32 @@ export function Session() {
   const sdk = useSDK()
 
   createEffect(async () => {
-    await sdk.client.session
-      .get({ sessionID: route.sessionID }, { throwOnError: true })
-      .then((x) => {
-        project.workspace.set(x.data?.workspaceID)
-      })
-      .then(() => sync.session.sync(route.sessionID))
-      .then(() => {
-        if (scroll) scroll.scrollBy(100_000)
-      })
-      .catch((e) => {
-        console.error(e)
-        toast.show({
-          message: `Session not found: ${route.sessionID}`,
-          variant: "error",
-        })
-        return navigate({ type: "home" })
+    const previousWorkspace = project.workspace.current()
+    const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
+    if (!result.data) {
+      toast.show({
+        message: `Session not found: ${route.sessionID}`,
+        variant: "error",
       })
+      navigate({ type: "home" })
+      return
+    }
+
+    if (result.data.workspaceID !== previousWorkspace) {
+      project.workspace.set(result.data.workspaceID)
+
+      // Sync all the data for this workspace. Note that this
+      // workspace may not exist anymore which is why this is not
+      // fatal. If it doesn't we still want to show the session
+      // (which will be non-interactive)
+      try {
+        await sync.bootstrap({ fatal: false })
+      } catch (e) {}
+    }
+    await sync.session.sync(route.sessionID)
+    if (scroll) scroll.scrollBy(100_000)
   })
 
-  // Handle initial prompt from fork
-  let seeded = false
   let lastSwitch: string | undefined = undefined
   event.on("message.part.updated", (evt) => {
     const part = evt.properties.part
@@ -217,14 +224,15 @@ export function Session() {
     }
   })
 
+  let seeded = false
   let scroll: ScrollBoxRenderable
   let prompt: PromptRef | undefined
   const bind = (r: PromptRef | undefined) => {
     prompt = r
     promptRef.set(r)
-    if (seeded || !route.initialPrompt || !r) return
+    if (seeded || !route.prompt || !r) return
     seeded = true
-    r.set(route.initialPrompt)
+    r.set(route.prompt)
   }
   const keybind = useKeybind()
   const dialog = useDialog()
@@ -597,7 +605,7 @@ export function Session() {
     {
       title: conceal() ? "Disable code concealment" : "Enable code concealment",
       value: "session.toggle.conceal",
-      keybind: "messages_toggle_conceal" as any,
+      keybind: "messages_toggle_conceal",
       category: "Session",
       onSelect: (dialog) => {
         setConceal((prev) => !prev)
@@ -989,31 +997,7 @@ export function Session() {
   const revertInfo = createMemo(() => session()?.revert)
   const revertMessageID = createMemo(() => revertInfo()?.messageID)
 
-  const revertDiffFiles = createMemo(() => {
-    const diffText = revertInfo()?.diff ?? ""
-    if (!diffText) return []
-
-    try {
-      const patches = parsePatch(diffText)
-      return patches.map((patch) => {
-        const filename = patch.newFileName || patch.oldFileName || "unknown"
-        const cleanFilename = filename.replace(/^[ab]\//, "")
-        return {
-          filename: cleanFilename,
-          additions: patch.hunks.reduce(
-            (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
-            0,
-          ),
-          deletions: patch.hunks.reduce(
-            (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
-            0,
-          ),
-        }
-      })
-    } catch {
-      return []
-    }
-  })
+  const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
 
   const revertRevertedMessages = createMemo(() => {
     const messageID = revertMessageID()
@@ -1934,28 +1918,26 @@ function Grep(props: ToolProps<typeof GrepTool>) {
 
 function WebFetch(props: ToolProps<typeof WebFetchTool>) {
   return (
-    <InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}>
-      WebFetch {(props.input as any).url}
+    <InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
+      WebFetch {props.input.url}
     </InlineTool>
   )
 }
 
-function CodeSearch(props: ToolProps<any>) {
-  const input = props.input as any
-  const metadata = props.metadata as any
+function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
+  const metadata = props.metadata as { results?: number }
   return (
-    <InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}>
-      Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
+    <InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
+      Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
     </InlineTool>
   )
 }
 
-function WebSearch(props: ToolProps<any>) {
-  const input = props.input as any
-  const metadata = props.metadata as any
+function WebSearch(props: ToolProps<typeof WebSearchTool>) {
+  const metadata = props.metadata as { numResults?: number }
   return (
-    <InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}>
-      Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
+    <InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
+      Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
     </InlineTool>
   )
 }
@@ -1979,7 +1961,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
     )
   })
 
-  const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
+  const current = createMemo(() =>
+    tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
+  )
 
   const isRunning = createMemo(() => props.part.state.status === "running")
 
@@ -1996,8 +1980,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
 
     if (isRunning() && tools().length > 0) {
       // content[0] += ` · ${tools().length} toolcalls`
-      if (current()) content.push(`↳ ${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`)
-      else content.push(`↳ ${tools().length} toolcalls`)
+      if (current()) {
+        const state = current()!.state
+        const title = state.status === "running" || state.status === "completed" ? state.title : undefined
+        content.push(`↳ ${Locale.titlecase(current()!.tool)} ${title}`)
+      } else content.push(`↳ ${tools().length} toolcalls`)
     }
 
     if (props.part.state.status === "completed") {

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

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

+ 18 - 0
packages/opencode/src/cli/cmd/tui/util/revert-diff.ts

@@ -0,0 +1,18 @@
+import { parsePatch } from "diff"
+
+export function getRevertDiffFiles(diffText: string) {
+  if (!diffText) return []
+
+  try {
+    return parsePatch(diffText).map((patch) => {
+      const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
+      return {
+        filename: filename.replace(/^[ab]\//, ""),
+        additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
+        deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
+      }
+    })
+  } catch {
+    return []
+  }
+}

+ 35 - 1
packages/opencode/src/cli/cmd/tui/util/signal.ts

@@ -1,7 +1,41 @@
-import { createSignal, type Accessor } from "solid-js"
+import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
 import { debounce, type Scheduled } from "@solid-primitives/scheduled"
 
 export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
   const [get, set] = createSignal(value)
   return [get, debounce((v: T) => set(() => v), ms)]
 }
+
+export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
+  const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
+  let revealed = show()
+
+  createEffect(
+    on([show, enabled], ([visible, animate]) => {
+      if (!visible) {
+        setAlpha(0)
+        return
+      }
+
+      if (!animate || revealed) {
+        revealed = true
+        setAlpha(1)
+        return
+      }
+
+      const start = performance.now()
+      revealed = true
+      setAlpha(0)
+
+      const timer = setInterval(() => {
+        const progress = Math.min((performance.now() - start) / 160, 1)
+        setAlpha(progress * progress * (3 - 2 * progress))
+        if (progress >= 1) clearInterval(timer)
+      }, 16)
+
+      onCleanup(() => clearInterval(timer))
+    }),
+  )
+
+  return alpha
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов