소스 검색

Merge branch 'dev' into brendan-cli-codesign

Brendan Allan 3 주 전
부모
커밋
a855229052
100개의 변경된 파일3993개의 추가작업 그리고 1632개의 파일을 삭제
  1. 1 0
      .github/VOUCHED.td
  2. 9 5
      bun.lock
  3. 0 1
      github/index.ts
  4. 4 4
      nix/hashes.json
  5. 4 4
      packages/app/src/app.tsx
  6. 38 12
      packages/app/src/components/dialog-connect-provider.tsx
  7. 1 0
      packages/app/src/components/prompt-input.tsx
  8. 38 12
      packages/app/src/components/settings-general.tsx
  9. 19 4
      packages/app/src/components/status-popover.tsx
  10. 4 1
      packages/app/src/components/terminal.tsx
  11. 1 1
      packages/app/src/components/titlebar.tsx
  12. 19 0
      packages/app/src/context/command-keybind.test.ts
  13. 28 30
      packages/app/src/context/global-sync.tsx
  14. 207 132
      packages/app/src/context/global-sync/bootstrap.ts
  15. 6 4
      packages/app/src/context/global-sync/event-reducer.test.ts
  16. 2 2
      packages/app/src/context/global-sync/event-reducer.ts
  17. 35 0
      packages/app/src/context/global-sync/utils.test.ts
  18. 15 1
      packages/app/src/context/global-sync/utils.ts
  19. 66 78
      packages/app/src/context/language.tsx
  20. 3 3
      packages/app/src/context/notification.tsx
  21. 12 1
      packages/app/src/context/settings.tsx
  22. 4 3
      packages/app/src/context/sync.tsx
  23. 12 39
      packages/app/src/context/terminal-title.ts
  24. 1 1
      packages/app/src/hooks/use-providers.ts
  25. 0 2
      packages/app/src/i18n/ar.ts
  26. 0 2
      packages/app/src/i18n/br.ts
  27. 0 2
      packages/app/src/i18n/bs.ts
  28. 0 2
      packages/app/src/i18n/da.ts
  29. 0 2
      packages/app/src/i18n/de.ts
  30. 2 2
      packages/app/src/i18n/en.ts
  31. 0 2
      packages/app/src/i18n/es.ts
  32. 0 2
      packages/app/src/i18n/fr.ts
  33. 0 2
      packages/app/src/i18n/ja.ts
  34. 0 2
      packages/app/src/i18n/ko.ts
  35. 0 2
      packages/app/src/i18n/no.ts
  36. 0 2
      packages/app/src/i18n/pl.ts
  37. 0 2
      packages/app/src/i18n/ru.ts
  38. 0 2
      packages/app/src/i18n/th.ts
  39. 0 2
      packages/app/src/i18n/tr.ts
  40. 0 2
      packages/app/src/i18n/zh.ts
  41. 0 2
      packages/app/src/i18n/zht.ts
  42. 1 0
      packages/app/src/index.ts
  43. 29 41
      packages/app/src/pages/directory-layout.tsx
  44. 8 0
      packages/app/src/pages/home.tsx
  45. 43 21
      packages/app/src/pages/layout.tsx
  46. 236 43
      packages/app/src/pages/session.tsx
  47. 2 1
      packages/app/src/pages/session/message-timeline.tsx
  48. 22 36
      packages/app/src/pages/session/session-side-panel.tsx
  49. 3 7
      packages/app/src/pages/session/use-session-commands.tsx
  50. 18 0
      packages/app/src/pages/session/use-session-hash-scroll.ts
  51. 23 1
      packages/app/src/utils/server-health.ts
  52. 81 96
      packages/app/src/utils/sound.ts
  53. 12 0
      packages/app/vite.js
  54. 1 1
      packages/desktop-electron/src/main/ipc.ts
  55. 1 1
      packages/desktop-electron/src/main/store.ts
  56. 17 3
      packages/desktop-electron/src/renderer/index.tsx
  57. 17 2
      packages/desktop/src/index.tsx
  58. 8 6
      packages/opencode/AGENTS.md
  59. 13 0
      packages/opencode/migration/20260323234822_events/migration.sql
  60. 1271 0
      packages/opencode/migration/20260323234822_events/snapshot.json
  61. 3 3
      packages/opencode/package.json
  62. 21 9
      packages/opencode/specs/effect-migration.md
  63. 2 2
      packages/opencode/src/account/index.ts
  64. 3 2
      packages/opencode/src/account/repo.ts
  65. 2 3
      packages/opencode/src/agent/agent.ts
  66. 2 2
      packages/opencode/src/auth/index.ts
  67. 0 3
      packages/opencode/src/bus/bus-event.ts
  68. 154 75
      packages/opencode/src/bus/index.ts
  69. 1 13
      packages/opencode/src/cli/cmd/agent.ts
  70. 6 7
      packages/opencode/src/cli/cmd/github.ts
  71. 4 4
      packages/opencode/src/cli/cmd/pr.ts
  72. 2 2
      packages/opencode/src/cli/cmd/tui/app.tsx
  73. 2 2
      packages/opencode/src/command/index.ts
  74. 0 1
      packages/opencode/src/config/config.ts
  75. 0 14
      packages/opencode/src/effect/instance-context.ts
  76. 8 4
      packages/opencode/src/effect/run-service.ts
  77. 13 25
      packages/opencode/src/file/index.ts
  78. 2 2
      packages/opencode/src/file/time.ts
  79. 4 4
      packages/opencode/src/file/watcher.ts
  80. 48 47
      packages/opencode/src/format/index.ts
  81. 308 0
      packages/opencode/src/git/index.ts
  82. 1 0
      packages/opencode/src/id/id.ts
  83. 2 2
      packages/opencode/src/installation/index.ts
  84. 6 0
      packages/opencode/src/lsp/index.ts
  85. 147 96
      packages/opencode/src/mcp/auth.ts
  86. 519 560
      packages/opencode/src/mcp/index.ts
  87. 26 3
      packages/opencode/src/mcp/oauth-callback.ts
  88. 2 2
      packages/opencode/src/permission/index.ts
  89. 14 10
      packages/opencode/src/plugin/index.ts
  90. 2 2
      packages/opencode/src/project/project.ts
  91. 161 35
      packages/opencode/src/project/vcs.ts
  92. 2 2
      packages/opencode/src/provider/auth.ts
  93. 2 2
      packages/opencode/src/pty/index.ts
  94. 2 2
      packages/opencode/src/question/index.ts
  95. 28 0
      packages/opencode/src/server/projectors.ts
  96. 7 8
      packages/opencode/src/server/routes/event.ts
  97. 97 44
      packages/opencode/src/server/routes/global.ts
  98. 3 3
      packages/opencode/src/server/routes/session.ts
  99. 44 5
      packages/opencode/src/server/server.ts
  100. 6 1
      packages/opencode/src/session/compaction.ts

+ 1 - 0
.github/VOUCHED.td

@@ -25,3 +25,4 @@ r44vc0rp
 rekram1-node
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
+-OpenCodeEngineer bot that spams issues

+ 9 - 5
bun.lock

@@ -329,7 +329,7 @@
         "@effect/platform-node": "catalog:",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
-        "@modelcontextprotocol/sdk": "1.25.2",
+        "@modelcontextprotocol/sdk": "1.27.1",
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
@@ -358,7 +358,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "5.3.2",
+        "gitlab-ai-provider": "5.3.3",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -1325,7 +1325,7 @@
 
     "@mixmark-io/domino": ["@mixmark-io/[email protected]", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
 
-    "@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]5.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
+    "@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]7.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
 
     "@motionone/animation": ["@motionone/[email protected]", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
 
@@ -2889,7 +2889,7 @@
 
     "express": ["[email protected]", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
 
-    "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
+    "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
 
     "expressive-code": ["[email protected]", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
 
@@ -3037,7 +3037,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["[email protected].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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
+    "gitlab-ai-provider": ["[email protected].3", "", { "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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
@@ -5129,6 +5129,8 @@
 
     "@modelcontextprotocol/sdk/express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
 
+    "@modelcontextprotocol/sdk/hono": ["[email protected]", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
+
     "@modelcontextprotocol/sdk/jose": ["[email protected]", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
 
     "@modelcontextprotocol/sdk/raw-body": ["[email protected]", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@@ -6311,6 +6313,8 @@
 
     "opencontrol/@modelcontextprotocol/sdk/express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
 
+    "opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["[email protected]", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
+
     "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["[email protected]", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
 
     "opencontrol/@modelcontextprotocol/sdk/raw-body": ["[email protected]", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],

+ 0 - 1
github/index.ts

@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
 
   const TOOL: Record<string, [string, string]> = {
     todowrite: ["Todo", "\x1b[33m\x1b[1m"],
-    todoread: ["Todo", "\x1b[33m\x1b[1m"],
     bash: ["Bash", "\x1b[31m\x1b[1m"],
     edit: ["Edit", "\x1b[32m\x1b[1m"],
     glob: ["Glob", "\x1b[34m\x1b[1m"],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
-    "aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
-    "aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
-    "x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
+    "x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
+    "aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
+    "aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
+    "x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
   }
 }

+ 4 - 4
packages/app/src/app.tsx

@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
 import { File } from "@opencode-ai/ui/file"
 import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
-import { ThemeProvider } from "@opencode-ai/ui/theme"
+import { ThemeProvider } from "@opencode-ai/ui/theme/context"
 import { MetaProvider } from "@solidjs/meta"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { HighlightsProvider } from "@/context/highlights"
-import { LanguageProvider, useLanguage } from "@/context/language"
+import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
 import { LayoutProvider } from "@/context/layout"
 import { ModelsProvider } from "@/context/models"
 import { NotificationProvider } from "@/context/notification"
@@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   )
 }
 
-export function AppBaseProviders(props: ParentProps) {
+export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
   return (
     <MetaProvider>
       <Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
           void window.api?.setTitlebar?.({ mode })
         }}
       >
-        <LanguageProvider>
+        <LanguageProvider locale={props.locale}>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
               <QueryProvider>

+ 38 - 12
packages/app/src/components/dialog-connect-provider.tsx

@@ -1,4 +1,4 @@
-import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
@@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
   })
 
   const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
-  const methods = createMemo(
-    () =>
-      globalSync.data.provider_auth[props.provider] ?? [
-        {
-          type: "api",
-          label: language.t("provider.connect.method.apiKey"),
-        },
-      ],
+  const fallback = createMemo<ProviderAuthMethod[]>(() => [
+    {
+      type: "api" as const,
+      label: language.t("provider.connect.method.apiKey"),
+    },
+  ])
+  const [auth] = createResource(
+    () => props.provider,
+    async () => {
+      const cached = globalSync.data.provider_auth[props.provider]
+      if (cached) return cached
+      const res = await globalSDK.client.provider.auth()
+      if (!alive.value) return fallback()
+      globalSync.set("provider_auth", res.data ?? {})
+      return res.data?.[props.provider] ?? fallback()
+    },
   )
+  const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
+  const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
       index: 0,
     })
 
-    const prompts = createMemo(() => method()?.prompts ?? [])
+    const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
+      const value = method()
+      if (value?.type !== "oauth") return []
+      return value.prompts ?? []
+    })
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       if (!prompt.when) return true
       const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
     listRef?.onKeyDown(e)
   }
 
-  onMount(() => {
+  let auto = false
+  createEffect(() => {
+    if (auto) return
+    if (loading()) return
     if (methods().length === 1) {
+      auto = true
       selectMethod(0)
     }
   })
@@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
         <div class="px-2.5 pb-10 flex flex-col gap-6">
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
             <Switch>
+              <Match when={loading()}>
+                <div class="text-14-regular text-text-base">
+                  <div class="flex items-center gap-x-2">
+                    <Spinner />
+                    <span>{language.t("provider.connect.status.inProgress")}</span>
+                  </div>
+                </div>
+              </Match>
               <Match when={store.methodIndex === undefined}>
                 <MethodSelection />
               </Match>

+ 1 - 0
packages/app/src/components/prompt-input.tsx

@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const open = recent()
       const seen = new Set(open)
       const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
+      if (!query.trim()) return [...agents, ...pinned]
       const paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))

+ 38 - 12
packages/app/src/components/settings-general.tsx

@@ -1,27 +1,41 @@
-import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
+import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
-import { playSound, SOUND_OPTIONS } from "@/utils/sound"
+import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
 import { Link } from "./link"
 import { SettingsList } from "./settings-list"
 
 let demoSoundState = {
   cleanup: undefined as (() => void) | undefined,
   timeout: undefined as NodeJS.Timeout | undefined,
+  run: 0,
+}
+
+type ThemeOption = {
+  id: string
+  name: string
+}
+
+let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
+
+function loadFont() {
+  font ??= import("@opencode-ai/ui/font-loader")
+  return font
 }
 
 // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
 // delay the playback by 100ms during quick selection changes and pause existing sounds.
 const stopDemoSound = () => {
+  demoSoundState.run += 1
   if (demoSoundState.cleanup) {
     demoSoundState.cleanup()
   }
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
   demoSoundState.cleanup = undefined
 }
 
-const playDemoSound = (src: string | undefined) => {
+const playDemoSound = (id: string | undefined) => {
   stopDemoSound()
-  if (!src) return
+  if (!id) return
 
+  const run = ++demoSoundState.run
   demoSoundState.timeout = setTimeout(() => {
-    demoSoundState.cleanup = playSound(src)
+    void playSoundById(id).then((cleanup) => {
+      if (demoSoundState.run !== run) {
+        cleanup?.()
+        return
+      }
+      demoSoundState.cleanup = cleanup
+    })
   }, 100)
 }
 
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
   const platform = usePlatform()
   const settings = useSettings()
 
+  onMount(() => {
+    void theme.loadThemes()
+  })
+
   const [store, setStore] = createStore({
     checking: false,
   })
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
       .finally(() => setStore("checking", false))
   }
 
-  const themeOptions = createMemo(() =>
-    Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
-  )
+  const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
   ] as const
   const fontOptionsList = [...fontOptions]
 
-  const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
+  const noneSound = { id: "none", label: "sound.option.none" } as const
   const soundOptions = [noneSound, ...SOUND_OPTIONS]
 
   const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
-      playDemoSound(option.src)
+      playDemoSound(option.id === "none" ? undefined : option.id)
     },
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
       }
       setEnabled(true)
       set(option.id)
-      playDemoSound(option.src)
+      playDemoSound(option.id)
     },
     variant: "secondary" as const,
     size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
             current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
             value={(o) => o.value}
             label={(o) => language.t(o.label)}
+            onHighlight={(option) => {
+              void loadFont().then((x) => x.ensureMonoFont(option?.value))
+            }}
             onSelect={(option) => option && settings.appearance.setFont(option.value)}
             variant="secondary"
             size="small"

+ 19 - 4
packages/app/src/components/status-popover.tsx

@@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-import { DialogSelectServer } from "./dialog-select-server"
 
 const pollMs = 10_000
 
@@ -54,11 +53,15 @@ const listServersByHealth = (
   })
 }
 
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
   const checkServerHealth = useCheckServerHealth()
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
   createEffect(() => {
+    if (!enabled()) {
+      setStatus(reconcile({}))
+      return
+    }
     const list = servers()
     let dead = false
 
@@ -162,6 +165,12 @@ export function StatusPopover() {
   const navigate = useNavigate()
 
   const [shown, setShown] = createSignal(false)
+  let dialogRun = 0
+  let dialogDead = false
+  onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
+  })
   const servers = createMemo(() => {
     const current = server.current
     const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
   })
-  const health = useServerHealth(servers)
+  const health = useServerHealth(servers, shown)
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const toggleMcp = useMcpToggleMutation()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
                 <Button
                   variant="secondary"
                   class="mt-3 self-start h-8 px-3 py-1.5"
-                  onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
+                  onClick={() => {
+                    const run = ++dialogRun
+                    void import("./dialog-select-server").then((x) => {
+                      if (dialogDead || dialogRun !== run) return
+                      dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
+                    })
+                  }}
                 >
                   {language.t("status.popover.action.manageServers")}
                 </Button>

+ 4 - 1
packages/app/src/components/terminal.tsx

@@ -1,4 +1,7 @@
-import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
+import { withAlpha } from "@opencode-ai/ui/theme/color"
+import { useTheme } from "@opencode-ai/ui/theme/context"
+import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
+import type { HexColor } from "@opencode-ai/ui/theme/types"
 import { showToast } from "@opencode-ai/ui/toast"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

+ 1 - 1
packages/app/src/components/titlebar.tsx

@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { useTheme } from "@opencode-ai/ui/theme"
+import { useTheme } from "@opencode-ai/ui/theme/context"
 
 import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"

+ 19 - 0
packages/app/src/context/command-keybind.test.ts

@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
     expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
   })
 
+  test("matchKeybind supports bracket keys", () => {
+    const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
+    const prev = keybinds[0]
+    const next = keybinds[1]
+
+    expect(
+      matchKeybind(
+        keybinds,
+        new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
+      ),
+    ).toBe(true)
+    expect(
+      matchKeybind(
+        keybinds,
+        new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
+      ),
+    ).toBe(true)
+  })
+
   test("formatKeybind returns human readable output", () => {
     const display = formatKeybind("ctrl+alt+arrowup")
 

+ 28 - 30
packages/app/src/context/global-sync.tsx

@@ -9,17 +9,7 @@ import type {
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
-import {
-  createContext,
-  getOwner,
-  Match,
-  onCleanup,
-  onMount,
-  type ParentProps,
-  Switch,
-  untrack,
-  useContext,
-} from "solid-js"
+import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
 
   let active = true
   let projectWritten = false
+  let bootedAt = 0
+  let bootingRoot = false
 
   onCleanup(() => {
     active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
         directory,
+        global: {
+          config: globalStore.config,
+          project: globalStore.project,
+          provider: globalStore.provider,
+        },
         sdk,
         store: child[0],
         setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
   const unsub = globalSDK.event.listen((e) => {
     const directory = e.name
     const event = e.details
+    const recent = bootingRoot || Date.now() - bootedAt < 1500
 
     if (directory === "global") {
       applyGlobalEvent({
         event,
         project: globalStore.project,
-        refresh: queue.refresh,
+        refresh: () => {
+          if (recent) return
+          queue.refresh()
+        },
         setGlobalProject: setProjects,
       })
       if (event.type === "server.connected" || event.type === "global.disposed") {
+        if (recent) return
         for (const directory of Object.keys(children.children)) {
           queue.push(directory)
         }
@@ -325,17 +327,19 @@ function createGlobalSync() {
   })
 
   async function bootstrap() {
-    await bootstrapGlobal({
-      globalSDK: globalSDK.client,
-      connectErrorTitle: language.t("dialog.server.add.error"),
-      connectErrorDescription: language.t("error.globalSync.connectFailed", {
-        url: globalSDK.url,
-      }),
-      requestFailedTitle: language.t("common.requestFailed"),
-      translate: language.t,
-      formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
-      setGlobalStore: setBootStore,
-    })
+    bootingRoot = true
+    try {
+      await bootstrapGlobal({
+        globalSDK: globalSDK.client,
+        requestFailedTitle: language.t("common.requestFailed"),
+        translate: language.t,
+        formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
+        setGlobalStore: setBootStore,
+      })
+      bootedAt = Date.now()
+    } finally {
+      bootingRoot = false
+    }
   }
 
   onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
 
 export function GlobalSyncProvider(props: ParentProps) {
   const value = createGlobalSync()
-  return (
-    <Switch>
-      <Match when={value.ready}>
-        <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
-      </Match>
-    </Switch>
-  )
+  return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
 }
 
 export function useGlobalSync() {

+ 207 - 132
packages/app/src/context/global-sync/bootstrap.ts

@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
 import { batch } from "solid-js"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
-import { cmp, normalizeProviderList } from "./utils"
+import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 
 type GlobalStore = {
@@ -31,73 +31,102 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
+function waitForPaint() {
+  return new Promise<void>((resolve) => {
+    let done = false
+    const finish = () => {
+      if (done) return
+      done = true
+      resolve()
+    }
+    const timer = setTimeout(finish, 50)
+    if (typeof requestAnimationFrame !== "function") return
+    requestAnimationFrame(() => {
+      clearTimeout(timer)
+      finish()
+    })
+  })
+}
+
+function errors(list: PromiseSettledResult<unknown>[]) {
+  return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
+}
+
+function runAll(list: Array<() => Promise<unknown>>) {
+  return Promise.allSettled(list.map((item) => item()))
+}
+
+function showErrors(input: {
+  errors: unknown[]
+  title: string
+  translate: (key: string, vars?: Record<string, string | number>) => string
+  formatMoreCount: (count: number) => string
+}) {
+  if (input.errors.length === 0) return
+  const message = formatServerError(input.errors[0], input.translate)
+  const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
+  showToast({
+    variant: "error",
+    title: input.title,
+    description: message + more,
+  })
+}
+
 export async function bootstrapGlobal(input: {
   globalSDK: OpencodeClient
-  connectErrorTitle: string
-  connectErrorDescription: string
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
 }) {
-  const health = await input.globalSDK.global
-    .health()
-    .then((x) => x.data)
-    .catch(() => undefined)
-  if (!health?.healthy) {
-    showToast({
-      variant: "error",
-      title: input.connectErrorTitle,
-      description: input.connectErrorDescription,
-    })
-    input.setGlobalStore("ready", true)
-    return
-  }
+  const fast = [
+    () =>
+      retry(() =>
+        input.globalSDK.path.get().then((x) => {
+          input.setGlobalStore("path", x.data!)
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.globalSDK.global.config.get().then((x) => {
+          input.setGlobalStore("config", x.data!)
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.globalSDK.provider.list().then((x) => {
+          input.setGlobalStore("provider", normalizeProviderList(x.data!))
+        }),
+      ),
+  ]
 
-  const tasks = [
-    retry(() =>
-      input.globalSDK.path.get().then((x) => {
-        input.setGlobalStore("path", x.data!)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.global.config.get().then((x) => {
-        input.setGlobalStore("config", x.data!)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.project.list().then((x) => {
-        const projects = (x.data ?? [])
-          .filter((p) => !!p?.id)
-          .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
-          .slice()
-          .sort((a, b) => cmp(a.id, b.id))
-        input.setGlobalStore("project", projects)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.provider.list().then((x) => {
-        input.setGlobalStore("provider", normalizeProviderList(x.data!))
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.provider.auth().then((x) => {
-        input.setGlobalStore("provider_auth", x.data ?? {})
-      }),
-    ),
+  const slow = [
+    () =>
+      retry(() =>
+        input.globalSDK.project.list().then((x) => {
+          const projects = (x.data ?? [])
+            .filter((p) => !!p?.id)
+            .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+            .slice()
+            .sort((a, b) => cmp(a.id, b.id))
+          input.setGlobalStore("project", projects)
+        }),
+      ),
   ]
 
-  const results = await Promise.allSettled(tasks)
-  const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
-  if (errors.length) {
-    const message = formatServerError(errors[0], input.translate)
-    const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
-    showToast({
-      variant: "error",
-      title: input.requestFailedTitle,
-      description: message + more,
-    })
-  }
+  showErrors({
+    errors: errors(await runAll(fast)),
+    title: input.requestFailedTitle,
+    translate: input.translate,
+    formatMoreCount: input.formatMoreCount,
+  })
+  await waitForPaint()
+  showErrors({
+    errors: errors(await runAll(slow)),
+    title: input.requestFailedTitle,
+    translate: input.translate,
+    formatMoreCount: input.formatMoreCount,
+  })
   input.setGlobalStore("ready", true)
 }
 
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
   }, {})
 }
 
+function projectID(directory: string, projects: Project[]) {
+  return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
+}
+
 export async function bootstrapDirectory(input: {
   directory: string
   sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
   translate: (key: string, vars?: Record<string, string | number>) => string
+  global: {
+    config: Config
+    project: Project[]
+    provider: ProviderListResponse
+  }
 }) {
-  if (input.store.status !== "complete") input.setStore("status", "loading")
-
-  const blockingRequests = {
-    project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
-    provider: () =>
-      input.sdk.provider.list().then((x) => {
-        input.setStore("provider", normalizeProviderList(x.data!))
-      }),
-    agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
-    config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+  const loading = input.store.status !== "complete"
+  const seededProject = projectID(input.directory, input.global.project)
+  if (seededProject) input.setStore("project", seededProject)
+  if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
+    input.setStore("provider", input.global.provider)
+  }
+  if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
+    input.setStore("config", input.global.config)
+  }
+  if (loading) input.setStore("status", "partial")
+
+  const fast = [
+    () =>
+      seededProject
+        ? Promise.resolve()
+        : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
+    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
+    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+    () =>
+      retry(() =>
+        input.sdk.path.get().then((x) => {
+          input.setStore("path", x.data!)
+          const next = projectID(x.data?.directory ?? input.directory, input.global.project)
+          if (next) input.setStore("project", next)
+        }),
+      ),
+    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
+    () =>
+      retry(() =>
+        input.sdk.vcs.get().then((x) => {
+          const next = x.data ?? input.store.vcs
+          input.setStore("vcs", next)
+          if (next) input.vcsCache.setStore("value", next)
+        }),
+      ),
+    () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
+    () =>
+      retry(() =>
+        input.sdk.permission.list().then((x) => {
+          const grouped = groupBySession(
+            (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+          )
+          batch(() => {
+            for (const sessionID of Object.keys(input.store.permission)) {
+              if (grouped[sessionID]) continue
+              input.setStore("permission", sessionID, [])
+            }
+            for (const [sessionID, permissions] of Object.entries(grouped)) {
+              input.setStore(
+                "permission",
+                sessionID,
+                reconcile(
+                  permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+          })
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.sdk.question.list().then((x) => {
+          const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+          batch(() => {
+            for (const sessionID of Object.keys(input.store.question)) {
+              if (grouped[sessionID]) continue
+              input.setStore("question", sessionID, [])
+            }
+            for (const [sessionID, questions] of Object.entries(grouped)) {
+              input.setStore(
+                "question",
+                sessionID,
+                reconcile(
+                  questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+          })
+        }),
+      ),
+  ]
+
+  const slow = [
+    () =>
+      retry(() =>
+        input.sdk.provider.list().then((x) => {
+          input.setStore("provider", normalizeProviderList(x.data!))
+        }),
+      ),
+    () => Promise.resolve(input.loadSessions(input.directory)),
+    () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
+    () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
+  ]
+
+  const errs = errors(await runAll(fast))
+  if (errs.length > 0) {
+    console.error("Failed to bootstrap instance", errs[0])
+    const project = getFilename(input.directory)
+    showToast({
+      variant: "error",
+      title: input.translate("toast.project.reloadFailed.title", { project }),
+      description: formatServerError(errs[0], input.translate),
+    })
   }
 
-  try {
-    await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
-  } catch (err) {
-    console.error("Failed to bootstrap instance", err)
+  await waitForPaint()
+  const slowErrs = errors(await runAll(slow))
+  if (slowErrs.length > 0) {
+    console.error("Failed to finish bootstrap instance", slowErrs[0])
     const project = getFilename(input.directory)
     showToast({
       variant: "error",
       title: input.translate("toast.project.reloadFailed.title", { project }),
-      description: formatServerError(err, input.translate),
+      description: formatServerError(slowErrs[0], input.translate),
     })
-    input.setStore("status", "partial")
-    return
   }
 
-  if (input.store.status !== "complete") input.setStore("status", "partial")
-
-  Promise.all([
-    input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
-    input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
-    input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
-    input.loadSessions(input.directory),
-    input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
-    input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
-    input.sdk.vcs.get().then((x) => {
-      const next = x.data ?? input.store.vcs
-      input.setStore("vcs", next)
-      if (next?.branch) input.vcsCache.setStore("value", next)
-    }),
-    input.sdk.permission.list().then((x) => {
-      const grouped = groupBySession(
-        (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
-      )
-      batch(() => {
-        for (const sessionID of Object.keys(input.store.permission)) {
-          if (grouped[sessionID]) continue
-          input.setStore("permission", sessionID, [])
-        }
-        for (const [sessionID, permissions] of Object.entries(grouped)) {
-          input.setStore(
-            "permission",
-            sessionID,
-            reconcile(
-              permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
-              { key: "id" },
-            ),
-          )
-        }
-      })
-    }),
-    input.sdk.question.list().then((x) => {
-      const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
-      batch(() => {
-        for (const sessionID of Object.keys(input.store.question)) {
-          if (grouped[sessionID]) continue
-          input.setStore("question", sessionID, [])
-        }
-        for (const [sessionID, questions] of Object.entries(grouped)) {
-          input.setStore(
-            "question",
-            sessionID,
-            reconcile(
-              questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
-              { key: "id" },
-            ),
-          )
-        }
-      })
-    }),
-  ]).then(() => {
-    input.setStore("status", "complete")
-  })
+  if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
 }

+ 6 - 4
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
   })
 
   test("updates vcs branch in store and cache", () => {
-    const [store, setStore] = createStore(baseState())
-    const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
+    const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
+    const [cacheStore, setCacheStore] = createStore({
+      value: { branch: "main", default_branch: "main" } as State["vcs"],
+    })
 
     applyDirectoryEvent({
       event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
       },
     })
 
-    expect(store.vcs).toEqual({ branch: "feature/test" })
-    expect(cacheStore.value).toEqual({ branch: "feature/test" })
+    expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
+    expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
   })
 
   test("routes disposal and lsp events to side-effect handlers", () => {

+ 2 - 2
packages/app/src/context/global-sync/event-reducer.ts

@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "vcs.branch.updated": {
-      const props = event.properties as { branch: string }
+      const props = event.properties as { branch?: string }
       if (input.store.vcs?.branch === props.branch) break
-      const next = { branch: props.branch }
+      const next = { ...input.store.vcs, branch: props.branch }
       input.setStore("vcs", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       break

+ 35 - 0
packages/app/src/context/global-sync/utils.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import type { Agent } from "@opencode-ai/sdk/v2/client"
+import { normalizeAgentList } from "./utils"
+
+const agent = (name = "build") =>
+  ({
+    name,
+    mode: "primary",
+    permission: {},
+    options: {},
+  }) as Agent
+
+describe("normalizeAgentList", () => {
+  test("keeps array payloads", () => {
+    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("wraps a single agent payload", () => {
+    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
+  })
+
+  test("extracts agents from keyed objects", () => {
+    expect(
+      normalizeAgentList({
+        build: agent("build"),
+        docs: agent("docs"),
+      }),
+    ).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("drops invalid payloads", () => {
+    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
+    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
+  })
+})

+ 15 - 1
packages/app/src/context/global-sync/utils.ts

@@ -1,7 +1,21 @@
-import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+function isAgent(input: unknown): input is Agent {
+  if (!input || typeof input !== "object") return false
+  const item = input as { name?: unknown; mode?: unknown }
+  if (typeof item.name !== "string") return false
+  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
+}
+
+export function normalizeAgentList(input: unknown): Agent[] {
+  if (Array.isArray(input)) return input.filter(isAgent)
+  if (isAgent(input)) return [input]
+  if (!input || typeof input !== "object") return []
+  return Object.values(input).filter(isAgent)
+}
+
 export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
   return {
     ...input,

+ 66 - 78
packages/app/src/context/language.tsx

@@ -1,42 +1,10 @@
 import * as i18n from "@solid-primitives/i18n"
-import { createEffect, createMemo } from "solid-js"
+import { createEffect, createMemo, createResource } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { Persist, persisted } from "@/utils/persist"
 import { dict as en } from "@/i18n/en"
-import { dict as zh } from "@/i18n/zh"
-import { dict as zht } from "@/i18n/zht"
-import { dict as ko } from "@/i18n/ko"
-import { dict as de } from "@/i18n/de"
-import { dict as es } from "@/i18n/es"
-import { dict as fr } from "@/i18n/fr"
-import { dict as da } from "@/i18n/da"
-import { dict as ja } from "@/i18n/ja"
-import { dict as pl } from "@/i18n/pl"
-import { dict as ru } from "@/i18n/ru"
-import { dict as ar } from "@/i18n/ar"
-import { dict as no } from "@/i18n/no"
-import { dict as br } from "@/i18n/br"
-import { dict as th } from "@/i18n/th"
-import { dict as bs } from "@/i18n/bs"
-import { dict as tr } from "@/i18n/tr"
 import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
-import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
-import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
-import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
-import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
-import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
-import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
-import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
-import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
-import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
-import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
-import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
-import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
-import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
-import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
-import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
-import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
 
 export type Locale =
   | "en"
@@ -59,6 +27,7 @@ export type Locale =
 
 type RawDictionary = typeof en & typeof uiEn
 type Dictionary = i18n.Flatten<RawDictionary>
+type Source = { dict: Record<string, string> }
 
 function cookie(locale: Locale) {
   return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
 }
 
 const base = i18n.flatten({ ...en, ...uiEn })
-const DICT: Record<Locale, Dictionary> = {
-  en: base,
-  zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
-  zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
-  ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
-  de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
-  es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
-  fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
-  da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
-  ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
-  pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
-  ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
-  ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
-  no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
-  br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
-  th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
-  bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
-  tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
+const dicts = new Map<Locale, Dictionary>([["en", base]])
+
+const merge = (app: Promise<Source>, ui: Promise<Source>) =>
+  Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
+
+const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
+  zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
+  zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
+  ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
+  de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
+  es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
+  fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
+  da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
+  ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
+  pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
+  ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
+  ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
+  no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
+  br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
+  th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
+  bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
+  tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
+}
+
+function loadDict(locale: Locale) {
+  const hit = dicts.get(locale)
+  if (hit) return Promise.resolve(hit)
+  if (locale === "en") return Promise.resolve(base)
+  const load = loaders[locale]
+  return load().then((next: Dictionary) => {
+    dicts.set(locale, next)
+    return next
+  })
+}
+
+export function loadLocaleDict(locale: Locale) {
+  return loadDict(locale).then(() => undefined)
 }
 
 const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
   { locale: "tr", match: (language) => language.startsWith("tr") },
 ]
 
-type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
-const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
-  zh,
-  zht,
-  ko,
-  de,
-  es,
-  fr,
-  da,
-  ja,
-  pl,
-  ru,
-  ar,
-  no,
-  br,
-  th,
-  bs,
-  tr,
-}
-void PARITY_CHECK
-
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
 
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
   return "en"
 }
 
-function normalizeLocale(value: string): Locale {
+export function normalizeLocale(value: string): Locale {
   return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
 }
 
+function readStoredLocale() {
+  if (typeof localStorage !== "object") return
+  try {
+    const raw = localStorage.getItem("opencode.global.dat:language")
+    if (!raw) return
+    const next = JSON.parse(raw) as { locale?: string }
+    if (typeof next?.locale !== "string") return
+    return normalizeLocale(next.locale)
+  } catch {
+    return
+  }
+}
+
+const warm = readStoredLocale() ?? detectLocale()
+if (warm !== "en") void loadDict(warm)
+
 export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
   name: "Language",
-  init: () => {
+  init: (props: { locale?: Locale }) => {
+    const initial = props.locale ?? readStoredLocale() ?? detectLocale()
     const [store, setStore, _, ready] = persisted(
       Persist.global("language", ["language.v1"]),
       createStore({
-        locale: detectLocale() as Locale,
+        locale: initial,
       }),
     )
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
-    console.log("locale", locale())
     const intl = createMemo(() => INTL[locale()])
 
-    const dict = createMemo<Dictionary>(() => DICT[locale()])
+    const [dict] = createResource(locale, loadDict, {
+      initialValue: dicts.get(initial) ?? base,
+    })
 
-    const t = i18n.translator(dict, i18n.resolveTemplate)
+    const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
+      key: keyof Dictionary,
+      params?: Record<string, string | number | boolean>,
+    ) => string
 
     const label = (value: Locale) => t(LABEL_KEY[value])
 

+ 3 - 3
packages/app/src/context/notification.tsx

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 
 type NotificationBase = {
   directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session.parentID) return
 
         if (settings.sounds.agentEnabled()) {
-          playSound(soundSrc(settings.sounds.agent()))
+          void playSoundById(settings.sounds.agent())
         }
 
         append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session?.parentID) return
 
         if (settings.sounds.errorsEnabled()) {
-          playSound(soundSrc(settings.sounds.errors()))
+          void playSoundById(settings.sounds.errors())
         }
 
         const error = "error" in event.properties ? event.properties.error : undefined

+ 12 - 1
packages/app/src/context/settings.tsx

@@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
   return createMemo(() => read() ?? fallback)
 }
 
+let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
+
+function loadFont() {
+  font ??= import("@opencode-ai/ui/font-loader")
+  return font
+}
+
 export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
   name: "Settings",
   init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
 
     createEffect(() => {
       if (typeof document === "undefined") return
-      document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
+      const id = store.appearance?.font ?? defaultSettings.appearance.font
+      if (id !== defaultSettings.appearance.font) {
+        void loadFont().then((x) => x.ensureMonoFont(id))
+      }
+      document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
     })
 
     return {

+ 4 - 3
packages/app/src/context/sync.tsx

@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
-    const messagePageSize = 200
+    const initialMessagePageSize = 80
+    const historyMessagePageSize = 200
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
@@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
             if (cached && hasSession && !opts?.force) return
 
-            const limit = meta.limit[key] ?? messagePageSize
+            const limit = meta.limit[key] ?? initialMessagePageSize
             const sessionReq =
               hasSession && !opts?.force
                 ? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const [, setStore] = globalSync.child(directory)
             touch(directory, setStore, sessionID)
             const key = keyFor(directory, sessionID)
-            const step = count ?? messagePageSize
+            const step = count ?? historyMessagePageSize
             if (meta.loading[key]) return
             if (meta.complete[key]) return
             const before = meta.cursor[key]

+ 12 - 39
packages/app/src/context/terminal-title.ts

@@ -1,45 +1,18 @@
-import { dict as ar } from "@/i18n/ar"
-import { dict as br } from "@/i18n/br"
-import { dict as bs } from "@/i18n/bs"
-import { dict as da } from "@/i18n/da"
-import { dict as de } from "@/i18n/de"
-import { dict as en } from "@/i18n/en"
-import { dict as es } from "@/i18n/es"
-import { dict as fr } from "@/i18n/fr"
-import { dict as ja } from "@/i18n/ja"
-import { dict as ko } from "@/i18n/ko"
-import { dict as no } from "@/i18n/no"
-import { dict as pl } from "@/i18n/pl"
-import { dict as ru } from "@/i18n/ru"
-import { dict as th } from "@/i18n/th"
-import { dict as tr } from "@/i18n/tr"
-import { dict as zh } from "@/i18n/zh"
-import { dict as zht } from "@/i18n/zht"
+const template = "Terminal {{number}}"
 
-const numbered = Array.from(
-  new Set([
-    en["terminal.title.numbered"],
-    ar["terminal.title.numbered"],
-    br["terminal.title.numbered"],
-    bs["terminal.title.numbered"],
-    da["terminal.title.numbered"],
-    de["terminal.title.numbered"],
-    es["terminal.title.numbered"],
-    fr["terminal.title.numbered"],
-    ja["terminal.title.numbered"],
-    ko["terminal.title.numbered"],
-    no["terminal.title.numbered"],
-    pl["terminal.title.numbered"],
-    ru["terminal.title.numbered"],
-    th["terminal.title.numbered"],
-    tr["terminal.title.numbered"],
-    zh["terminal.title.numbered"],
-    zht["terminal.title.numbered"],
-  ]),
-)
+const numbered = [
+  template,
+  "محطة طرفية {{number}}",
+  "Терминал {{number}}",
+  "ターミナル {{number}}",
+  "터미널 {{number}}",
+  "เทอร์มินัล {{number}}",
+  "终端 {{number}}",
+  "終端機 {{number}}",
+]
 
 export function defaultTitle(number: number) {
-  return en["terminal.title.numbered"].replace("{{number}}", String(number))
+  return template.replace("{{number}}", String(number))
 }
 
 export function isDefaultTitle(title: string, number: number) {

+ 1 - 1
packages/app/src/hooks/use-providers.ts

@@ -22,7 +22,7 @@ export function useProviders() {
   const providers = () => {
     if (dir()) {
       const [projectStore] = globalSync.child(dir())
-      return projectStore.provider
+      if (projectStore.provider.all.length > 0) return projectStore.provider
     }
     return globalSync.data.provider
   }

+ 0 - 2
packages/app/src/i18n/ar.ts

@@ -722,8 +722,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
-  "settings.permissions.tool.todoread.title": "قراءة المهام",
-  "settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
   "settings.permissions.tool.todowrite.title": "كتابة المهام",
   "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
   "settings.permissions.tool.webfetch.title": "جلب الويب",

+ 0 - 2
packages/app/src/i18n/br.ts

@@ -732,8 +732,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
-  "settings.permissions.tool.todoread.title": "Ler Tarefas",
-  "settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
   "settings.permissions.tool.todowrite.title": "Escrever Tarefas",
   "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
   "settings.permissions.tool.webfetch.title": "Buscar Web",

+ 0 - 2
packages/app/src/i18n/bs.ts

@@ -806,8 +806,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
-  "settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
-  "settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
   "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
   "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
   "settings.permissions.tool.webfetch.title": "Web preuzimanje",

+ 0 - 2
packages/app/src/i18n/da.ts

@@ -800,8 +800,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
-  "settings.permissions.tool.todoread.title": "Læs To-do",
-  "settings.permissions.tool.todoread.description": "Læs to-do listen",
   "settings.permissions.tool.todowrite.title": "Skriv To-do",
   "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
   "settings.permissions.tool.webfetch.title": "Webhentning",

+ 0 - 2
packages/app/src/i18n/de.ts

@@ -743,8 +743,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
-  "settings.permissions.tool.todoread.title": "Todo lesen",
-  "settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
   "settings.permissions.tool.todowrite.title": "Todo schreiben",
   "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
   "settings.permissions.tool.webfetch.title": "Web-Abruf",

+ 2 - 2
packages/app/src/i18n/en.ts

@@ -535,6 +535,8 @@ export const dict = {
   "session.review.noVcs.createGit.action": "Create Git repository",
   "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
   "session.review.noChanges": "No changes",
+  "session.review.noUncommittedChanges": "No uncommitted changes yet",
+  "session.review.noBranchChanges": "No branch changes yet",
 
   "session.files.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
@@ -900,8 +902,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Load a skill by name",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Run language server queries",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Read the todo list",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Update the todo list",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/es.ts

@@ -813,8 +813,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
-  "settings.permissions.tool.todoread.title": "Leer Todo",
-  "settings.permissions.tool.todoread.description": "Leer la lista de tareas",
   "settings.permissions.tool.todowrite.title": "Escribir Todo",
   "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/fr.ts

@@ -741,8 +741,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Charger une compétence par son nom",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
-  "settings.permissions.tool.todoread.title": "Lire Todo",
-  "settings.permissions.tool.todoread.description": "Lire la liste de tâches",
   "settings.permissions.tool.todowrite.title": "Écrire Todo",
   "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
   "settings.permissions.tool.webfetch.title": "Récupération Web",

+ 0 - 2
packages/app/src/i18n/ja.ts

@@ -727,8 +727,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
-  "settings.permissions.tool.todoread.title": "Todo読み込み",
-  "settings.permissions.tool.todoread.description": "Todoリストの読み込み",
   "settings.permissions.tool.todowrite.title": "Todo書き込み",
   "settings.permissions.tool.todowrite.description": "Todoリストの更新",
   "settings.permissions.tool.webfetch.title": "Web取得",

+ 0 - 2
packages/app/src/i18n/ko.ts

@@ -726,8 +726,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "이름으로 기술 로드",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
-  "settings.permissions.tool.todoread.title": "할 일 읽기",
-  "settings.permissions.tool.todoread.description": "할 일 목록 읽기",
   "settings.permissions.tool.todowrite.title": "할 일 쓰기",
   "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
   "settings.permissions.tool.webfetch.title": "웹 가져오기",

+ 0 - 2
packages/app/src/i18n/no.ts

@@ -807,8 +807,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
-  "settings.permissions.tool.todoread.title": "Les gjøremål",
-  "settings.permissions.tool.todoread.description": "Les gjøremålslisten",
   "settings.permissions.tool.todowrite.title": "Skriv gjøremål",
   "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
   "settings.permissions.tool.webfetch.title": "Webhenting",

+ 0 - 2
packages/app/src/i18n/pl.ts

@@ -729,8 +729,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
-  "settings.permissions.tool.todoread.title": "Odczyt Todo",
-  "settings.permissions.tool.todoread.description": "Odczyt listy zadań",
   "settings.permissions.tool.todowrite.title": "Zapis Todo",
   "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
   "settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

+ 0 - 2
packages/app/src/i18n/ru.ts

@@ -808,8 +808,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Загрузка навыка по имени",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Чтение списка задач",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Обновление списка задач",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 0 - 2
packages/app/src/i18n/th.ts

@@ -796,8 +796,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
-  "settings.permissions.tool.todoread.title": "อ่านรายการงาน",
-  "settings.permissions.tool.todoread.description": "อ่านรายการงาน",
   "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
   "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
   "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

+ 0 - 2
packages/app/src/i18n/tr.ts

@@ -816,8 +816,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
-  "settings.permissions.tool.todoread.title": "Görev Oku",
-  "settings.permissions.tool.todoread.description": "Görev listesini oku",
   "settings.permissions.tool.todowrite.title": "Görev Yaz",
   "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
   "settings.permissions.tool.webfetch.title": "Web Getir",

+ 0 - 2
packages/app/src/i18n/zh.ts

@@ -795,8 +795,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名称加载技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "运行语言服务器查询",
-  "settings.permissions.tool.todoread.title": "读取待办",
-  "settings.permissions.tool.todoread.description": "读取待办列表",
   "settings.permissions.tool.todowrite.title": "更新待办",
   "settings.permissions.tool.todowrite.description": "更新待办列表",
   "settings.permissions.tool.webfetch.title": "网页获取",

+ 0 - 2
packages/app/src/i18n/zht.ts

@@ -790,8 +790,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名稱載入技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
-  "settings.permissions.tool.todoread.title": "讀取待辦",
-  "settings.permissions.tool.todoread.description": "讀取待辦清單",
   "settings.permissions.tool.todowrite.title": "更新待辦",
   "settings.permissions.tool.todowrite.description": "更新待辦清單",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 1 - 0
packages/app/src/index.ts

@@ -1,6 +1,7 @@
 export { AppBaseProviders, AppInterface } from "./app"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
+export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"

+ 29 - 41
packages/app/src/pages/directory-layout.tsx

@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createMemo, createResource, type ParentProps, Show } from "solid-js"
-import { useGlobalSDK } from "@/context/global-sdk"
+import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { LocalProvider } from "@/context/local"
 import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
 import { decode64 } from "@/utils/base64"
 
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+  const location = useLocation()
   const navigate = useNavigate()
   const sync = useSync()
   const slug = createMemo(() => base64Encode(props.directory))
 
+  createEffect(() => {
+    const next = sync.data.path.directory
+    if (!next || next === props.directory) return
+    const path = location.pathname.slice(slug().length + 1)
+    navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+  })
+
   return (
     <DataProvider
       data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
-  const location = useLocation()
   const language = useLanguage()
-  const globalSDK = useGlobalSDK()
   const navigate = useNavigate()
   let invalid = ""
 
-  const [resolved] = createResource(
-    () => {
-      if (params.dir) return [location.pathname, params.dir] as const
-    },
-    async ([pathname, b64Dir]) => {
-      const directory = decode64(b64Dir)
+  const resolved = createMemo(() => {
+    if (!params.dir) return ""
+    return decode64(params.dir) ?? ""
+  })
 
-      if (!directory) {
-        if (invalid === params.dir) return
-        invalid = b64Dir
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: language.t("directory.error.invalidUrl"),
-        })
-        navigate("/", { replace: true })
-        return
-      }
-
-      return await globalSDK
-        .createClient({
-          directory,
-          throwOnError: true,
-        })
-        .path.get()
-        .then((x) => {
-          const next = x.data?.directory ?? directory
-          invalid = ""
-          if (next === directory) return next
-          const path = pathname.slice(b64Dir.length + 1)
-          navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
-        })
-        .catch(() => {
-          invalid = ""
-          return directory
-        })
-    },
-  )
+  createEffect(() => {
+    const dir = params.dir
+    if (!dir) return
+    if (resolved()) {
+      invalid = ""
+      return
+    }
+    if (invalid === dir) return
+    invalid = dir
+    showToast({
+      variant: "error",
+      title: language.t("common.requestFailed"),
+      description: language.t("directory.error.invalidUrl"),
+    })
+    navigate("/", { replace: true })
+  })
 
   return (
     <Show when={resolved()} keyed>

+ 8 - 0
packages/app/src/pages/home.tsx

@@ -113,6 +113,14 @@ export default function Home() {
             </ul>
           </div>
         </Match>
+        <Match when={!sync.ready}>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
+            <Button class="px-3" onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </div>
+        </Match>
         <Match when={true}>
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
             <Icon name="folder-add-left" size="large" />

+ 43 - 21
packages/app/src/pages/layout.tsx

@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
 import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
-import { DialogSelectProvider } from "@/components/dialog-select-provider"
-import { DialogSelectServer } from "@/components/dialog-select-server"
-import { DialogSettings } from "@/components/dialog-settings"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
-import { DialogSelectDirectory } from "@/components/dialog-select-directory"
-import { DialogEditProject } from "@/components/dialog-edit-project"
 import { DebugBar } from "@/components/debug-bar"
 import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
   const pageReady = createMemo(() => ready())
 
   let scrollContainerRef: HTMLDivElement | undefined
+  let dialogRun = 0
+  let dialogDead = false
 
   const params = useParams()
   const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
       dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
     }
   })
-  const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
+  const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
   const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
     system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
   })
 
   onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     clearTimeout(sortNowTimeout)
     if (sortNowInterval) clearInterval(sortNowInterval)
@@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) {
     const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
     const nextThemeId = ids[nextIndex]
     theme.setTheme(nextThemeId)
-    const nextTheme = theme.themes()[nextThemeId]
     showToast({
       title: language.t("toast.theme.title"),
-      description: nextTheme?.name ?? nextThemeId,
+      description: theme.name(nextThemeId),
     })
   }
 
@@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) {
 
         if (e.details.type === "permission.asked") {
           if (settings.sounds.permissionsEnabled()) {
-            playSound(soundSrc(settings.sounds.permissions()))
+            void playSoundById(settings.sounds.permissions())
           }
           if (settings.notifications.permissions()) {
             void platform.notify(title, description, href)
@@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
         : projects[(index + offset + projects.length) % projects.length]
     if (!target) return
 
+    // warm up child store to prevent flicker
+    globalSync.child(target.worktree)
     openProject(target.worktree)
   }
 
@@ -1152,10 +1152,10 @@ export default function Layout(props: ParentProps) {
       },
     ]
 
-    for (const [id, definition] of availableThemeEntries()) {
+    for (const [id] of availableThemeEntries()) {
       commands.push({
         id: `theme.set.${id}`,
-        title: language.t("command.theme.set", { theme: definition.name ?? id }),
+        title: language.t("command.theme.set", { theme: theme.name(id) }),
         category: language.t("command.category.theme"),
         onSelect: () => theme.commitPreview(),
         onHighlight: () => {
@@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
   })
 
   function connectProvider() {
-    dialog.show(() => <DialogSelectProvider />)
+    const run = ++dialogRun
+    void import("@/components/dialog-select-provider").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
   }
 
   function openServer() {
-    dialog.show(() => <DialogSelectServer />)
+    const run = ++dialogRun
+    void import("@/components/dialog-select-server").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSelectServer />)
+    })
   }
 
   function openSettings() {
-    dialog.show(() => <DialogSettings />)
+    const run = ++dialogRun
+    void import("@/components/dialog-settings").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSettings />)
+    })
   }
 
   function projectRoot(directory: string) {
@@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
     layout.sidebar.toggleWorkspaces(project.worktree)
   }
 
-  const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
+  const showEditProjectDialog = (project: LocalProject) => {
+    const run = ++dialogRun
+    void import("@/components/dialog-edit-project").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogEditProject project={project} />)
+    })
+  }
 
   async function chooseProject() {
     function resolve(result: string | string[] | null) {
@@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
       })
       resolve(result)
     } else {
-      dialog.show(
-        () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
-        () => resolve(null),
-      )
+      const run = ++dialogRun
+      void import("@/components/dialog-select-directory").then((x) => {
+        if (dialogDead || dialogRun !== run) return
+        dialog.show(
+          () => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
+          () => resolve(null),
+        )
+      })
     }
   }
 

+ 236 - 43
packages/app/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
+import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMutation } from "@tanstack/solid-query"
 import {
@@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
 const emptyUserMessages: UserMessage[] = []
 const emptyFollowups: (FollowupDraft & { id: string })[] = []
 
+type ChangeMode = "git" | "branch" | "session" | "turn"
+type VcsMode = "git" | "branch"
+
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
   messagesReady: () => boolean
@@ -424,15 +427,16 @@ export default function Page() {
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-  const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
-  const hasReview = createMemo(() => reviewCount() > 0)
+  const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
+  const hasSessionReview = createMemo(() => sessionCount() > 0)
+  const canReview = createMemo(() => !!params.id)
   const reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
     tabs,
     pathFromTab: file.pathFromTab,
     normalizeTab,
     review: reviewTab,
-    hasReview,
+    hasReview: canReview,
   })
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
@@ -455,6 +459,12 @@ export default function Page() {
     if (!id) return false
     return sync.session.history.loading(id)
   })
+  const diffsReady = createMemo(() => {
+    const id = params.id
+    if (!id) return true
+    if (!hasSessionReview()) return true
+    return sync.data.session_diff[id] !== undefined
+  })
 
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -508,11 +518,22 @@ export default function Page() {
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
     mobileTab: "session" as "session" | "changes",
-    changes: "session" as "session" | "turn",
+    changes: "git" as ChangeMode,
     newSessionWorktree: "main",
     deferRender: false,
   })
 
+  const [vcs, setVcs] = createStore({
+    diff: {
+      git: [] as FileDiff[],
+      branch: [] as FileDiff[],
+    },
+    ready: {
+      git: false,
+      branch: false,
+    },
+  })
+
   const [followup, setFollowup] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
     failed: {} as Record<string, string | undefined>,
@@ -539,6 +560,68 @@ export default function Page() {
   let refreshTimer: 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, 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()
@@ -554,7 +637,42 @@ export default function Page() {
   }, desktopReviewOpen())
 
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
-  const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
+  const changesOptions = createMemo<ChangeMode[]>(() => {
+    const list: ChangeMode[] = []
+    if (sync.project?.vcs === "git") list.push("git")
+    if (
+      sync.project?.vcs === "git" &&
+      sync.data.vcs?.branch &&
+      sync.data.vcs?.default_branch &&
+      sync.data.vcs.branch !== sync.data.vcs.default_branch
+    ) {
+      list.push("branch")
+    }
+    list.push("session", "turn")
+    return list
+  })
+  const vcsMode = createMemo<VcsMode | undefined>(() => {
+    if (store.changes === "git" || store.changes === "branch") return store.changes
+  })
+  const reviewDiffs = createMemo(() => {
+    if (store.changes === "git") return vcs.diff.git
+    if (store.changes === "branch") return vcs.diff.branch
+    if (store.changes === "session") return diffs()
+    return turnDiffs()
+  })
+  const reviewCount = createMemo(() => {
+    if (store.changes === "git") return vcs.diff.git.length
+    if (store.changes === "branch") return vcs.diff.branch.length
+    if (store.changes === "session") return sessionCount()
+    return turnDiffs().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
+    if (store.changes === "session") return !hasSessionReview() || diffsReady()
+    return true
+  })
 
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
@@ -620,13 +738,7 @@ export default function Page() {
     scrollToMessage(msgs[targetIndex], "auto")
   }
 
-  const diffsReady = createMemo(() => {
-    const id = params.id
-    if (!id) return true
-    if (!hasReview()) return true
-    return sync.data.session_diff[id] !== undefined
-  })
-  const reviewEmptyKey = createMemo(() => {
+  const sessionEmptyKey = createMemo(() => {
     const project = sync.project
     if (project && !project.vcs) return "session.review.noVcs"
     if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -748,13 +860,46 @@ export default function Page() {
       sessionKey,
       () => {
         setStore("messageId", undefined)
-        setStore("changes", "session")
+        setStore("changes", "git")
         setUi("pendingMessage", undefined)
       },
       { defer: true },
     ),
   )
 
+  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 =
+      typeof evt.details.properties === "object" && evt.details.properties
+        ? (evt.details.properties as Record<string, unknown>)
+        : undefined
+    const file = typeof props?.file === "string" ? props.file : undefined
+    if (!file || file.startsWith(".git/")) return
+    refreshVcs()
+  })
+  onCleanup(stopVcs)
+
   createEffect(
     on(
       () => params.dir,
@@ -877,6 +1022,40 @@ 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
+    const next = list[0]
+    if (!next) return
+    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)
+      },
+      { defer: true },
+    ),
+  )
 
   const fileTreeTab = () => layout.fileTree.tab()
   const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -923,21 +1102,23 @@ export default function Page() {
     loadFile: file.load,
   })
 
-  const changesOptions = ["session", "turn"] as const
-  const changesOptionsList = [...changesOptions]
-
   const changesTitle = () => {
-    if (!hasReview()) {
+    if (!canReview()) {
       return null
     }
 
+    const label = (option: ChangeMode) => {
+      if (option === "git") return language.t("ui.sessionReview.title.git")
+      if (option === "branch") return language.t("ui.sessionReview.title.branch")
+      if (option === "session") return language.t("ui.sessionReview.title")
+      return language.t("ui.sessionReview.title.lastTurn")
+    }
+
     return (
       <Select
-        options={changesOptionsList}
+        options={changesOptions()}
         current={store.changes}
-        label={(option) =>
-          option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
-        }
+        label={label}
         onSelect={(option) => option && setStore("changes", option)}
         variant="ghost"
         size="small"
@@ -946,20 +1127,34 @@ export default function Page() {
     )
   }
 
-  const emptyTurn = () => (
+  const empty = (text: string) => (
     <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
-      <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
+      <div class="text-14-regular text-text-weak max-w-56">{text}</div>
     </div>
   )
 
+  const reviewEmptyText = createMemo(() => {
+    if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
+    if (store.changes === "branch") return language.t("session.review.noBranchChanges")
+    if (store.changes === "turn") return language.t("session.review.noChanges")
+    return language.t(sessionEmptyKey())
+  })
+
   const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
-    if (store.changes === "turn") return emptyTurn()
+    if (store.changes === "git" || store.changes === "branch") {
+      if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
+      return empty(reviewEmptyText())
+    }
+
+    if (store.changes === "turn") {
+      return empty(reviewEmptyText())
+    }
 
-    if (hasReview() && !diffsReady()) {
+    if (hasSessionReview() && !diffsReady()) {
       return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
     }
 
-    if (reviewEmptyKey() === "session.review.noVcs") {
+    if (sessionEmptyKey() === "session.review.noVcs") {
       return (
         <div class={input.emptyClass}>
           <div class="flex flex-col gap-3">
@@ -979,7 +1174,7 @@ export default function Page() {
 
     return (
       <div class={input.emptyClass}>
-        <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
+        <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
       </div>
     )
   }
@@ -1083,7 +1278,7 @@ export default function Page() {
     const pending = tree.pendingDiff
     if (!pending) return
     if (!tree.reviewScroll) return
-    if (!diffsReady()) return
+    if (!reviewReady()) return
 
     const attempt = (count: number) => {
       if (tree.pendingDiff !== pending) return
@@ -1124,10 +1319,7 @@ export default function Page() {
     const id = params.id
     if (!id) return
 
-    const wants = isDesktop()
-      ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-      : store.mobileTab === "changes"
-    if (!wants) return
+    if (!wantsReview()) return
     if (sync.data.session_diff[id] !== undefined) return
     if (sync.status === "loading") return
 
@@ -1136,13 +1328,7 @@ export default function Page() {
 
   createEffect(
     on(
-      () =>
-        [
-          sessionKey(),
-          isDesktop()
-            ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-            : store.mobileTab === "changes",
-        ] as const,
+      () => [sessionKey(), wantsReview()] as const,
       ([key, wants]) => {
         if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
         if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1184,8 +1370,6 @@ export default function Page() {
     on(
       () => sdk.directory,
       () => {
-        void file.tree.list("")
-
         const tab = activeFileTab()
         if (!tab) return
         const path = file.pathFromTab(tab)
@@ -1640,6 +1824,9 @@ export default function Page() {
     sessionID: () => params.id,
     messagesReady,
     visibleUserMessages,
+    historyMore,
+    historyLoading,
+    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
     turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1898,7 @@ export default function Page() {
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
               <Match when={params.id}>
-                <Show when={lastUserMessage()}>
+                <Show when={messagesReady()}>
                   <MessageTimeline
                     mobileChanges={mobileChanges()}
                     mobileFallback={reviewContent({
@@ -1827,6 +2014,12 @@ export default function Page() {
         </div>
 
         <SessionSidePanel
+          canReview={canReview}
+          diffs={reviewDiffs}
+          diffsReady={reviewReady}
+          empty={reviewEmptyText}
+          hasReview={hasReview}
+          reviewCount={reviewCount}
           reviewPanel={reviewPanel}
           activeDiff={tree.activeDiff}
           focusReviewDiff={focusReviewDiff}

+ 2 - 1
packages/app/src/pages/session/message-timeline.tsx

@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
             </Show>
             <div
               role="log"
-              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              data-slot="session-turn-list"
+              class="flex flex-col items-start justify-start pb-16 transition-[margin]"
               classList={{
                 "w-full": true,
                 "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,

+ 22 - 36
packages/app/src/pages/session/session-side-panel.tsx

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
-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"
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 
 export function SessionSidePanel(props: {
+  canReview: () => boolean
+  diffs: () => FileDiff[]
+  diffsReady: () => boolean
+  empty: () => string
+  hasReview: () => boolean
+  reviewCount: () => number
   reviewPanel: () => JSX.Element
   activeDiff?: string
   focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
   size: Sizing
 }) {
   const layout = useLayout()
-  const sync = useSync()
   const file = useFile()
   const language = useLanguage()
   const command = useCommand()
   const dialog = useDialog()
-  const { params, sessionKey, tabs, view } = useSessionLayout()
+  const { sessionKey, tabs, view } = useSessionLayout()
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
 
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
   })
   const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-  const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
-  const hasReview = createMemo(() => reviewCount() > 0)
-  const diffsReady = createMemo(() => {
-    const id = params.id
-    if (!id) return true
-    if (!hasReview()) return true
-    return sync.data.session_diff[id] !== undefined
-  })
-
-  const reviewEmptyKey = createMemo(() => {
-    if (sync.project && !sync.project.vcs) return "session.review.noVcs"
-    if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
-    return "session.review.noChanges"
-  })
-
-  const diffFiles = createMemo(() => diffs().map((d) => d.file))
+  const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
   const kinds = createMemo(() => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
       if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
     const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
 
     const out = new Map<string, "add" | "del" | "mix">()
-    for (const diff of diffs()) {
+    for (const diff of props.diffs()) {
       const file = normalize(diff.file)
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
     pathFromTab: file.pathFromTab,
     normalizeTab,
     review: reviewTab,
-    hasReview,
+    hasReview: props.canReview,
   })
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
                         onCleanup(stop)
                       }}
                     >
-                      <Show when={reviewTab()}>
+                      <Show when={reviewTab() && props.canReview()}>
                         <Tabs.Trigger value="review">
                           <div class="flex items-center gap-1.5">
                             <div>{language.t("session.tab.review")}</div>
-                            <Show when={hasReview()}>
-                              <div>{reviewCount()}</div>
+                            <Show when={props.hasReview()}>
+                              <div>{props.reviewCount()}</div>
                             </Show>
                           </div>
                         </Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
                     </Tabs.List>
                   </div>
 
-                  <Show when={reviewTab()}>
+                  <Show when={reviewTab() && props.canReview()}>
                     <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                       <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
                     </Tabs.Content>
@@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
               >
                 <Tabs.List>
                   <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
-                    {reviewCount()}{" "}
-                    {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
+                    {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")}
@@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
                 </Tabs.List>
                 <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
                   <Switch>
-                    <Match when={hasReview()}>
+                    <Match when={props.hasReview() || !props.diffsReady()}>
                       <Show
-                        when={diffsReady()}
+                        when={props.diffsReady()}
                         fallback={
                           <div class="px-2 py-2 text-12-regular text-text-weak">
                             {language.t("common.loading")}
@@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
                         />
                       </Show>
                     </Match>
-                    <Match when={true}>
-                      {empty(
-                        language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
-                      )}
-                    </Match>
+                    <Match when={true}>{empty(props.empty())}</Match>
                   </Switch>
                 </Tabs.Content>
                 <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">

+ 3 - 7
packages/app/src/pages/session/use-session-commands.tsx

@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     if (!id) return
     return sync.session.get(id)
   }
-  const hasReview = () => {
-    const id = params.id
-    if (!id) return false
-    return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
-  }
+  const hasReview = () => !!params.id
   const normalizeTab = (tab: string) => {
     if (!tab.startsWith("file://")) return tab
     return file.tab(tab)
@@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.previous",
         title: language.t("command.message.previous"),
         description: language.t("command.message.previous.description"),
-        keybind: "mod+arrowup",
+        keybind: "mod+alt+[",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(-1),
       }),
@@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.next",
         title: language.t("command.message.next"),
         description: language.t("command.message.next.description"),
-        keybind: "mod+arrowdown",
+        keybind: "mod+alt+]",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(1),
       }),

+ 18 - 0
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
   sessionID: () => string | undefined
   messagesReady: () => boolean
   visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
   turnStart: () => number
   currentMessageId: () => string | undefined
   pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
     queue(() => scrollToMessage(msg, "auto"))
   })
 
+  createEffect(() => {
+    const sessionID = input.sessionID()
+    if (!sessionID || !input.messagesReady()) return
+
+    visibleUserMessages()
+
+    let targetId = input.pendingMessage()
+    if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
+    if (!targetId) return
+    if (messageById().has(targetId)) return
+    if (!input.historyMore() || input.historyLoading()) return
+
+    void input.loadMore(sessionID)
+  })
+
   onMount(() => {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
       window.history.scrollRestoration = "manual"

+ 23 - 1
packages/app/src/utils/server-health.ts

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
 const defaultTimeoutMs = 3000
 const defaultRetryCount = 2
 const defaultRetryDelayMs = 100
+const cacheMs = 750
+const healthCache = new Map<
+  string,
+  { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
+>()
+
+function cacheKey(server: ServerConnection.HttpBase) {
+  return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
+}
 
 function timeoutSignal(timeoutMs: number) {
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
   const platform = usePlatform()
   const fetcher = platform.fetch ?? globalThis.fetch
 
-  return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
+  return (http: ServerConnection.HttpBase) => {
+    const key = cacheKey(http)
+    const hit = healthCache.get(key)
+    const now = Date.now()
+    if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
+    const promise = checkServerHealth(http, fetcher).finally(() => {
+      const next = healthCache.get(key)
+      if (!next || next.promise !== promise) return
+      next.done = true
+      next.at = Date.now()
+    })
+    healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
+    return promise
+  }
 }

+ 81 - 96
packages/app/src/utils/sound.ts

@@ -1,106 +1,89 @@
-import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
-import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
-import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
-import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
-import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
-import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
-import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
-import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
-import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
-import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
-import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
-import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
-import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
-import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
-import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
-import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
-import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
-import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
-import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
-import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
-import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
-import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
-import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
-import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
-import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
-import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
-import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
-import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
-import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
-import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
-import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
-import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
-import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
-import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
-import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
-import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
-import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
-import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
-import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
-import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
-import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
-import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
-import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
-import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
-import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
+let files: Record<string, () => Promise<string>> | undefined
+let loads: Record<SoundID, () => Promise<string>> | undefined
+
+function getFiles() {
+  if (files) return files
+  files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
+    string,
+    () => Promise<string>
+  >
+  return files
+}
 
 export const SOUND_OPTIONS = [
-  { id: "alert-01", label: "sound.option.alert01", src: alert01 },
-  { id: "alert-02", label: "sound.option.alert02", src: alert02 },
-  { id: "alert-03", label: "sound.option.alert03", src: alert03 },
-  { id: "alert-04", label: "sound.option.alert04", src: alert04 },
-  { id: "alert-05", label: "sound.option.alert05", src: alert05 },
-  { id: "alert-06", label: "sound.option.alert06", src: alert06 },
-  { id: "alert-07", label: "sound.option.alert07", src: alert07 },
-  { id: "alert-08", label: "sound.option.alert08", src: alert08 },
-  { id: "alert-09", label: "sound.option.alert09", src: alert09 },
-  { id: "alert-10", label: "sound.option.alert10", src: alert10 },
-  { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
-  { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
-  { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
-  { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
-  { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
-  { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
-  { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
-  { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
-  { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
-  { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
-  { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
-  { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
-  { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
-  { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
-  { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
-  { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
-  { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
-  { id: "nope-01", label: "sound.option.nope01", src: nope01 },
-  { id: "nope-02", label: "sound.option.nope02", src: nope02 },
-  { id: "nope-03", label: "sound.option.nope03", src: nope03 },
-  { id: "nope-04", label: "sound.option.nope04", src: nope04 },
-  { id: "nope-05", label: "sound.option.nope05", src: nope05 },
-  { id: "nope-06", label: "sound.option.nope06", src: nope06 },
-  { id: "nope-07", label: "sound.option.nope07", src: nope07 },
-  { id: "nope-08", label: "sound.option.nope08", src: nope08 },
-  { id: "nope-09", label: "sound.option.nope09", src: nope09 },
-  { id: "nope-10", label: "sound.option.nope10", src: nope10 },
-  { id: "nope-11", label: "sound.option.nope11", src: nope11 },
-  { id: "nope-12", label: "sound.option.nope12", src: nope12 },
-  { id: "yup-01", label: "sound.option.yup01", src: yup01 },
-  { id: "yup-02", label: "sound.option.yup02", src: yup02 },
-  { id: "yup-03", label: "sound.option.yup03", src: yup03 },
-  { id: "yup-04", label: "sound.option.yup04", src: yup04 },
-  { id: "yup-05", label: "sound.option.yup05", src: yup05 },
-  { id: "yup-06", label: "sound.option.yup06", src: yup06 },
+  { id: "alert-01", label: "sound.option.alert01" },
+  { id: "alert-02", label: "sound.option.alert02" },
+  { id: "alert-03", label: "sound.option.alert03" },
+  { id: "alert-04", label: "sound.option.alert04" },
+  { id: "alert-05", label: "sound.option.alert05" },
+  { id: "alert-06", label: "sound.option.alert06" },
+  { id: "alert-07", label: "sound.option.alert07" },
+  { id: "alert-08", label: "sound.option.alert08" },
+  { id: "alert-09", label: "sound.option.alert09" },
+  { id: "alert-10", label: "sound.option.alert10" },
+  { id: "bip-bop-01", label: "sound.option.bipbop01" },
+  { id: "bip-bop-02", label: "sound.option.bipbop02" },
+  { id: "bip-bop-03", label: "sound.option.bipbop03" },
+  { id: "bip-bop-04", label: "sound.option.bipbop04" },
+  { id: "bip-bop-05", label: "sound.option.bipbop05" },
+  { id: "bip-bop-06", label: "sound.option.bipbop06" },
+  { id: "bip-bop-07", label: "sound.option.bipbop07" },
+  { id: "bip-bop-08", label: "sound.option.bipbop08" },
+  { id: "bip-bop-09", label: "sound.option.bipbop09" },
+  { id: "bip-bop-10", label: "sound.option.bipbop10" },
+  { id: "staplebops-01", label: "sound.option.staplebops01" },
+  { id: "staplebops-02", label: "sound.option.staplebops02" },
+  { id: "staplebops-03", label: "sound.option.staplebops03" },
+  { id: "staplebops-04", label: "sound.option.staplebops04" },
+  { id: "staplebops-05", label: "sound.option.staplebops05" },
+  { id: "staplebops-06", label: "sound.option.staplebops06" },
+  { id: "staplebops-07", label: "sound.option.staplebops07" },
+  { id: "nope-01", label: "sound.option.nope01" },
+  { id: "nope-02", label: "sound.option.nope02" },
+  { id: "nope-03", label: "sound.option.nope03" },
+  { id: "nope-04", label: "sound.option.nope04" },
+  { id: "nope-05", label: "sound.option.nope05" },
+  { id: "nope-06", label: "sound.option.nope06" },
+  { id: "nope-07", label: "sound.option.nope07" },
+  { id: "nope-08", label: "sound.option.nope08" },
+  { id: "nope-09", label: "sound.option.nope09" },
+  { id: "nope-10", label: "sound.option.nope10" },
+  { id: "nope-11", label: "sound.option.nope11" },
+  { id: "nope-12", label: "sound.option.nope12" },
+  { id: "yup-01", label: "sound.option.yup01" },
+  { id: "yup-02", label: "sound.option.yup02" },
+  { id: "yup-03", label: "sound.option.yup03" },
+  { id: "yup-04", label: "sound.option.yup04" },
+  { id: "yup-05", label: "sound.option.yup05" },
+  { id: "yup-06", label: "sound.option.yup06" },
 ] as const
 
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundID = SoundOption["id"]
 
-const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
+function getLoads() {
+  if (loads) return loads
+  loads = Object.fromEntries(
+    Object.entries(getFiles()).flatMap(([path, load]) => {
+      const file = path.split("/").at(-1)
+      if (!file) return []
+      return [[file.replace(/\.aac$/, ""), load] as const]
+    }),
+  ) as Record<SoundID, () => Promise<string>>
+  return loads
+}
+
+const cache = new Map<SoundID, Promise<string | undefined>>()
 
 export function soundSrc(id: string | undefined) {
-  if (!id) return
-  if (!(id in soundById)) return
-  return soundById[id as SoundID]
+  const loads = getLoads()
+  if (!id || !(id in loads)) return Promise.resolve(undefined)
+  const key = id as SoundID
+  const hit = cache.get(key)
+  if (hit) return hit
+  const next = loads[key]().catch(() => undefined)
+  cache.set(key, next)
+  return next
 }
 
 export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
   if (!src) return
   const audio = new Audio(src)
   audio.play().catch(() => undefined)
-
-  // Return a cleanup function to pause the sound.
   return () => {
     audio.pause()
     audio.currentTime = 0
   }
 }
+
+export function playSoundById(id: string | undefined) {
+  return soundSrc(id).then((src) => playSound(src))
+}

+ 12 - 0
packages/app/vite.js

@@ -1,7 +1,10 @@
+import { readFileSync } from "node:fs"
 import solidPlugin from "vite-plugin-solid"
 import tailwindcss from "@tailwindcss/vite"
 import { fileURLToPath } from "url"
 
+const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
+
 /**
  * @type {import("vite").PluginOption}
  */
@@ -21,6 +24,15 @@ export default [
       }
     },
   },
+  {
+    name: "opencode-desktop:theme-preload",
+    transformIndexHtml(html) {
+      return html.replace(
+        '<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
+        `<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
+      )
+    },
+  },
   tailwindcss(),
   solidPlugin(),
 ]

+ 1 - 1
packages/desktop-electron/src/main/ipc.ts

@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
     "open-directory-picker",
     async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
       const result = await dialog.showOpenDialog({
-        properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
+        properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
         title: opts?.title ?? "Choose a folder",
         defaultPath: opts?.defaultPath,
       })

+ 1 - 1
packages/desktop-electron/src/main/store.ts

@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
 export function getStore(name = SETTINGS_STORE) {
   const cached = cache.get(name)
   if (cached) return cached
-  const next = new Store({ name })
+  const next = new Store({ name, fileExtension: "" })
   cache.set(name, next)
   return next
 }

+ 17 - 3
packages/desktop-electron/src/renderer/index.tsx

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppInterface,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   PlatformProvider,
   ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
 
 render(() => {
   const platform = createPlatform()
+  const loadLocale = async () => {
+    const current = await platform.storage?.("opencode.global.dat").getItem("language")
+    const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
+    const raw = current ?? legacy
+    if (!raw) return
+    const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
+    if (!locale) return
+    const next = normalizeLocale(locale)
+    if (next !== "en") await loadLocaleDict(next)
+    return next satisfies Locale
+  }
 
   const [windowCount] = createResource(() => window.api.getWindowCount())
 
@@ -257,6 +271,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
   )
+  const [locale] = createResource(loadLocale)
 
   const servers = () => {
     const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
 
   return (
     <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
+      <AppBaseProviders locale={locale.latest}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
           {(_) => {
             return (
               <AppInterface
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 servers={servers()}
                 router={MemoryRouter}
-                disableHealthCheck={(windowCount() ?? 0) > 1}
               >
                 <Inner />
               </AppInterface>

+ 17 - 2
packages/desktop/src/index.tsx

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppInterface,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   PlatformProvider,
   ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
 
 render(() => {
   const platform = createPlatform()
+  const loadLocale = async () => {
+    const current = await platform.storage?.("opencode.global.dat").getItem("language")
+    const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
+    const raw = current ?? legacy
+    if (!raw) return
+    const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
+    if (!locale) return
+    const next = normalizeLocale(locale)
+    if (next !== "en") await loadLocaleDict(next)
+    return next satisfies Locale
+  }
 
   // Fetch sidecar credentials from Rust (available immediately, before health check)
   const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
   )
+  const [locale] = createResource(loadLocale)
 
   // Build the sidecar server connection once credentials arrive
   const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
 
   return (
     <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading}>
+      <AppBaseProviders locale={locale.latest}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
           {(_) => {
             return (
               <AppInterface

+ 8 - 6
packages/opencode/AGENTS.md

@@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
 - Use `Schema.Defect` instead of `unknown` for defect-like causes.
 - In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
 
-## Runtime vs Instances
+## Runtime vs InstanceState
 
-- Use the shared runtime for process-wide services with one lifecycle for the whole app.
-- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
-- If two open directories should not share one copy of the service, it belongs in `Instances`.
-- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
+- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
+- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
+- If two open directories should not share one copy of the service, it needs `InstanceState`.
+- 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.
 
 ## Preferred Effect services
 
@@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
 
 `Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
 
-Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
+Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
 
 You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
 

+ 13 - 0
packages/opencode/migration/20260323234822_events/migration.sql

@@ -0,0 +1,13 @@
+CREATE TABLE `event_sequence` (
+	`aggregate_id` text PRIMARY KEY,
+	`seq` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `event` (
+	`id` text PRIMARY KEY,
+	`aggregate_id` text NOT NULL,
+	`seq` integer NOT NULL,
+	`type` text NOT NULL,
+	`data` text NOT NULL,
+	CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
+);

+ 1271 - 0
packages/opencode/migration/20260323234822_events/snapshot.json

@@ -0,0 +1,1271 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed",
+  "prevIds": ["37e1554d-af4c-43f2-aa7c-307fb49a315e"],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_account_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_org_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "columns": ["active_account_id"],
+      "tableTo": "account",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "SET NULL",
+      "nameExplicit": false,
+      "name": "fk_account_state_active_account_id_account_id_fk",
+      "entityType": "fks",
+      "table": "account_state"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": ["message_id"],
+      "tableTo": "message",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": ["aggregate_id"],
+      "tableTo": "event_sequence",
+      "columnsTo": ["aggregate_id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": ["email", "url"],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": ["session_id", "position"],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["project_id"],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["session_id"],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["aggregate_id"],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 3 - 3
packages/opencode/package.json

@@ -92,7 +92,7 @@
     "@effect/platform-node": "catalog:",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
-    "@modelcontextprotocol/sdk": "1.25.2",
+    "@modelcontextprotocol/sdk": "1.27.1",
     "@octokit/graphql": "9.0.2",
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",
@@ -121,7 +121,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "5.3.2",
+    "gitlab-ai-provider": "5.3.3",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
@@ -133,9 +133,9 @@
     "minimatch": "10.0.3",
     "open": "10.1.2",
     "opencode-gitlab-auth": "2.0.0",
+    "opencode-poe-auth": "0.0.1",
     "opentui-spinner": "0.0.6",
     "partial-json": "0.1.7",
-    "opencode-poe-auth": "0.0.1",
     "remeda": "catalog:",
     "semver": "^7.6.3",
     "solid-js": "catalog:",

+ 21 - 9
packages/opencode/specs/effect-migration.md

@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
 
 Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
 
-Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
+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, Installation, Truncate
 - Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
@@ -46,7 +46,7 @@ export namespace Foo {
   export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
 
   // Per-service runtime (inside the namespace)
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   // Async facade functions
   export async function get(id: FooID) {
@@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
 
 The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
 
-- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
+- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
 
 ```ts
+const bus = yield * Bus.Service
+
 const cache =
   yield *
   InstanceState.make<State>(
     Effect.fn("Foo.state")(function* (ctx) {
       // ... load state ...
 
-      yield* Effect.acquireRelease(
-        Effect.sync(() =>
-          Bus.subscribeAll((event) => {
+      yield* bus.subscribeAll().pipe(
+        Stream.runForEach((event) =>
+          Effect.sync(() => {
             /* handle */
           }),
         ),
-        (unsub) => Effect.sync(unsub),
+        Effect.forkScoped,
       )
 
       return {
@@ -104,6 +106,16 @@ const cache =
   )
 ```
 
+- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
+
+```ts
+yield *
+  Effect.acquireRelease(
+    Effect.sync(() => nativeAddon.watch(dir)),
+    (watcher) => Effect.sync(() => watcher.close()),
+  )
+```
+
 - **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
 - **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
 
@@ -165,7 +177,7 @@ Still open and likely worth migrating:
 - [x] `ToolRegistry`
 - [ ] `Pty`
 - [x] `Worktree`
-- [ ] `Bus`
+- [x] `Bus`
 - [x] `Command`
 - [ ] `Config`
 - [ ] `Session`
@@ -175,4 +187,4 @@ Still open and likely worth migrating:
 - [ ] `Provider`
 - [x] `Project`
 - [ ] `LSP`
-- [ ] `MCP`
+- [x] `MCP`

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

@@ -1,7 +1,7 @@
 import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { AccountRepo, type AccountRow } from "./repo"
 import {
@@ -379,7 +379,7 @@ export namespace Account {
 
   export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
 
-  export const runPromise = makeRunPromise(Service, defaultLayer)
+  export const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function active(): Promise<Info | undefined> {
     return Option.getOrUndefined(await runPromise((service) => service.active()))

+ 3 - 2
packages/opencode/src/account/repo.ts

@@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
 export type AccountRow = (typeof AccountTable)["$inferSelect"]
 
 type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
+type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
 
 const ACCOUNT_STATE_ID = 1
 
@@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
     Effect.gen(function* () {
       const decode = Schema.decodeUnknownSync(Info)
 
-      const query = <A>(f: (db: DbClient) => A) =>
+      const query = <A>(f: DbTransactionCallback<A>) =>
         Effect.try({
           try: () => Database.use(f),
           catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
         })
 
-      const tx = <A>(f: (db: DbClient) => A) =>
+      const tx = <A>(f: DbTransactionCallback<A>) =>
         Effect.try({
           try: () => Database.transaction(f),
           catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),

+ 2 - 3
packages/opencode/src/agent/agent.ts

@@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
 import { Effect, ServiceMap, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Agent {
   export const Info = z
@@ -148,7 +148,6 @@ export namespace Agent {
               permission: Permission.merge(
                 defaults,
                 Permission.fromConfig({
-                  todoread: "deny",
                   todowrite: "deny",
                 }),
                 user,
@@ -394,7 +393,7 @@ export namespace Agent {
 
   export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(agent: string) {
     return runPromise((svc) => svc.get(agent))

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

@@ -1,6 +1,6 @@
 import path from "path"
 import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { zod } from "@/util/effect-zod"
 import { Global } from "../global"
 import { Filesystem } from "../util/filesystem"
@@ -95,7 +95,7 @@ export namespace Auth {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function get(providerID: string) {
     return runPromise((service) => service.get(providerID))

+ 0 - 3
packages/opencode/src/bus/bus-event.ts

@@ -1,10 +1,7 @@
 import z from "zod"
 import type { ZodType } from "zod"
-import { Log } from "../util/log"
 
 export namespace BusEvent {
-  const log = Log.create({ service: "event" })
-
   export type Definition = ReturnType<typeof define>
 
   const registry = new Map<string, Definition>()

+ 154 - 75
packages/opencode/src/bus/index.ts

@@ -1,12 +1,14 @@
 import z from "zod"
+import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
 import { Log } from "../util/log"
 import { Instance } from "../project/instance"
 import { BusEvent } from "./bus-event"
 import { GlobalBus } from "./global"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Bus {
   const log = Log.create({ service: "bus" })
-  type Subscription = (event: any) => void
 
   export const InstanceDisposed = BusEvent.define(
     "server.instance.disposed",
@@ -15,91 +17,168 @@ export namespace Bus {
     }),
   )
 
-  const state = Instance.state(
-    () => {
-      const subscriptions = new Map<any, Subscription[]>()
+  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 ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const cache = 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 {
-        subscriptions,
+          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>>
+        })
       }
-    },
-    async (entry) => {
-      const wildcard = entry.subscriptions.get("*")
-      if (!wildcard) return
-      const event = {
-        type: InstanceDisposed.type,
-        properties: {
-          directory: Instance.directory,
-        },
+
+      function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
+        return Effect.gen(function* () {
+          const state = yield* InstanceState.get(cache)
+          const payload: Payload = { type: def.type, properties }
+          log.info("publishing", { type: def.type })
+
+          const ps = state.typed.get(def.type)
+          if (ps) yield* PubSub.publish(ps, payload)
+          yield* PubSub.publish(state.wildcard, payload)
+
+          GlobalBus.emit("event", {
+            directory: Instance.directory,
+            payload,
+          })
+        })
       }
-      for (const sub of [...wildcard]) {
-        sub(event)
+
+      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 state = yield* InstanceState.get(cache)
+            const ps = yield* getOrCreate(state, def)
+            return Stream.fromPubSub(ps)
+          }),
+        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
       }
-    },
-  )
 
-  export async function publish<Definition extends BusEvent.Definition>(
-    def: Definition,
-    properties: z.output<Definition["properties"]>,
-  ) {
-    const payload = {
-      type: def.type,
-      properties,
-    }
-    log.info("publishing", {
-      type: def.type,
-    })
-    const pending = []
-    for (const key of [def.type, "*"]) {
-      const match = [...(state().subscriptions.get(key) ?? [])]
-      for (const sub of match) {
-        pending.push(sub(payload))
+      function subscribeAll(): Stream.Stream<Payload> {
+        log.info("subscribing", { type: "*" })
+        return Stream.unwrap(
+          Effect.gen(function* () {
+            const state = yield* InstanceState.get(cache)
+            return Stream.fromPubSub(state.wildcard)
+          }),
+        ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
       }
-    }
-    GlobalBus.emit("event", {
-      directory: Instance.directory,
-      payload,
-    })
-    return Promise.all(pending)
-  }
 
-  export function subscribe<Definition extends BusEvent.Definition>(
-    def: Definition,
-    callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
-  ) {
-    return raw(def.type, callback)
-  }
+      function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
+        return Effect.gen(function* () {
+          log.info("subscribing", { type })
+          const scope = yield* Scope.make()
+          const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
 
-  export function once<Definition extends BusEvent.Definition>(
-    def: Definition,
-    callback: (event: {
-      type: Definition["type"]
-      properties: z.infer<Definition["properties"]>
-    }) => "done" | undefined,
-  ) {
-    const unsub = subscribe(def, (event) => {
-      if (callback(event)) unsub()
-    })
+          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 })
+            Effect.runFork(Scope.close(scope, Exit.void))
+          }
+        })
+      }
+
+      const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
+        def: D,
+        callback: (event: Payload<D>) => unknown,
+      ) {
+        const state = yield* InstanceState.get(cache)
+        const ps = yield* getOrCreate(state, def)
+        return yield* on(ps, def.type, callback)
+      })
+
+      const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
+        const state = yield* InstanceState.get(cache)
+        return yield* on(state.wildcard, "*", callback)
+      })
+
+      return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
+    }),
+  )
+
+  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 subscribeAll(callback: (event: any) => void) {
-    return raw("*", callback)
+  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))
   }
 
-  function raw(type: string, callback: (event: any) => void) {
-    log.info("subscribing", { type })
-    const subscriptions = state().subscriptions
-    let match = subscriptions.get(type) ?? []
-    match.push(callback)
-    subscriptions.set(type, match)
-
-    return () => {
-      log.info("unsubscribing", { type })
-      const match = subscriptions.get(type)
-      if (!match) return
-      const index = match.indexOf(callback)
-      if (index === -1) return
-      match.splice(index, 1)
-    }
+  export function subscribeAll(callback: (event: any) => unknown) {
+    return runSync((svc) => svc.subscribeAllCallback(callback))
   }
 }

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

@@ -14,19 +14,7 @@ import type { Argv } from "yargs"
 
 type AgentMode = "all" | "primary" | "subagent"
 
-const AVAILABLE_TOOLS = [
-  "bash",
-  "read",
-  "write",
-  "edit",
-  "list",
-  "glob",
-  "grep",
-  "webfetch",
-  "task",
-  "todowrite",
-  "todoread",
-]
+const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
 
 const AgentCreateCommand = cmd({
   command: "create",

+ 6 - 7
packages/opencode/src/cli/cmd/github.ts

@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
+import { Git } from "@/git"
 import { setTimeout as sleep } from "node:timers/promises"
 import { Process } from "@/util/process"
-import { git } from "@/util/git"
 
 type GitHubAuthor = {
   login: string
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
             }
 
             // Get repo info
-            const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
+            const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
             const parsed = parseGitHubRemote(info)
             if (!parsed) {
               prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
           : "issue"
         : undefined
       const gitText = async (args: string[]) => {
-        const result = await git(args, { cwd: Instance.worktree })
+        const result = await Git.run(args, { cwd: Instance.worktree })
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result.text().trim()
       }
       const gitRun = async (args: string[]) => {
-        const result = await git(args, { cwd: Instance.worktree })
+        const result = await Git.run(args, { cwd: Instance.worktree })
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result
       }
-      const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
+      const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
       const commitChanges = async (summary: string, actor?: string) => {
         const args = ["commit", "-m", summary]
         if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -869,7 +869,6 @@ export const GithubRunCommand = cmd({
       function subscribeSessionEvents() {
         const TOOL: Record<string, [string, string]> = {
           todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
-          todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
           bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
           edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
           glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
@@ -890,7 +889,7 @@ export const GithubRunCommand = cmd({
         }
 
         let text = ""
-        Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
+        Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
           if (evt.properties.part.sessionID !== session.id) return
           //if (evt.properties.part.messageID === messageID) return
           const part = evt.properties.part

+ 4 - 4
packages/opencode/src/cli/cmd/pr.ts

@@ -1,8 +1,8 @@
 import { UI } from "../ui"
 import { cmd } from "./cmd"
+import { Git } from "@/git"
 import { Instance } from "@/project/instance"
 import { Process } from "@/util/process"
-import { git } from "@/util/git"
 
 export const PrCommand = cmd({
   command: "pr <number>",
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
               const remoteName = forkOwner
 
               // Check if remote already exists
-              const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
+              const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
               if (!remotes.split("\n").includes(remoteName)) {
-                await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
+                await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
                   cwd: Instance.worktree,
                 })
                 UI.println(`Added fork remote: ${remoteName}`)
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
 
               // Set upstream to the fork so pushes go there
               const headRefName = prInfo.headRefName
-              await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
+              await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
                 cwd: Instance.worktree,
               })
             }

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

@@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     })
   })
 
-  sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
+  sdk.event.on("session.deleted", (evt) => {
     if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
       route.navigate({ type: "home" })
       toast.show({
@@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     }
   })
 
-  sdk.event.on(SessionApi.Event.Error.type, (evt) => {
+  sdk.event.on("session.error", (evt) => {
     const error = evt.properties.error
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
     const message = (() => {

+ 2 - 2
packages/opencode/src/command/index.ts

@@ -1,6 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, ServiceMap } from "effect"
 import z from "zod"
@@ -173,7 +173,7 @@ export namespace Command {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function get(name: string) {
     return runPromise((svc) => svc.get(name))

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

@@ -673,7 +673,6 @@ export namespace Config {
           task: PermissionRule.optional(),
           external_directory: PermissionRule.optional(),
           todowrite: PermissionAction.optional(),
-          todoread: PermissionAction.optional(),
           question: PermissionAction.optional(),
           webfetch: PermissionAction.optional(),
           websearch: PermissionAction.optional(),

+ 0 - 14
packages/opencode/src/effect/instance-context.ts

@@ -1,14 +0,0 @@
-import { ServiceMap } from "effect"
-import type { Project } from "@/project/project"
-
-export declare namespace InstanceContext {
-  export interface Shape {
-    readonly directory: string
-    readonly worktree: string
-    readonly project: Project.Info
-  }
-}
-
-export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
-  "opencode/InstanceContext",
-) {}

+ 8 - 4
packages/opencode/src/effect/run-service.ts

@@ -3,11 +3,15 @@ import * as ServiceMap from "effect/ServiceMap"
 
 export const memoMap = Layer.makeMemoMapUnsafe()
 
-export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
+export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
   let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+  const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
 
-  return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
-    rt ??= ManagedRuntime.make(layer, { memoMap })
-    return rt.runPromise(service.use(fn), options)
+  return {
+    runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+    runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+      getRuntime().runPromise(service.use(fn), options),
+    runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
+    runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
   }
 }

+ 13 - 25
packages/opencode/src/file/index.ts

@@ -1,8 +1,8 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { makeRuntime } from "@/effect/run-service"
+import { Git } from "@/git"
+import { Effect, Layer, ServiceMap } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import fs from "fs"
 import fuzzysort from "fuzzysort"
@@ -323,7 +323,6 @@ export namespace File {
 
   interface State {
     cache: Entry
-    fiber: Fiber.Fiber<void> | undefined
   }
 
   export interface Interface {
@@ -348,7 +347,6 @@ export namespace File {
         Effect.fn("File.state")(() =>
           Effect.succeed({
             cache: { files: [], dirs: [] } as Entry,
-            fiber: undefined as Fiber.Fiber<void> | undefined,
           }),
         ),
       )
@@ -406,21 +404,11 @@ export namespace File {
         s.cache = next
       })
 
-      const scope = yield* Scope.Scope
+      let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
 
       const ensure = Effect.fn("File.ensure")(function* () {
-        const s = yield* InstanceState.get(state)
-        if (!s.fiber)
-          s.fiber = yield* scan().pipe(
-            Effect.catchCause(() => Effect.void),
-            Effect.ensuring(
-              Effect.sync(() => {
-                s.fiber = undefined
-              }),
-            ),
-            Effect.forkIn(scope),
-          )
-        yield* Fiber.join(s.fiber)
+        yield* cachedScan
+        cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
       })
 
       const init = Effect.fn("File.init")(function* () {
@@ -432,7 +420,7 @@ export namespace File {
 
         return yield* Effect.promise(async () => {
           const diffOutput = (
-            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+            await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
               cwd: Instance.directory,
             })
           ).text()
@@ -452,7 +440,7 @@ export namespace File {
           }
 
           const untrackedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -485,7 +473,7 @@ export namespace File {
           }
 
           const deletedOutput = (
-            await git(
+            await Git.run(
               [
                 "-c",
                 "core.fsmonitor=false",
@@ -576,17 +564,17 @@ export namespace File {
 
           if (Instance.project.vcs === "git") {
             let diff = (
-              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
+              await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
             ).text()
             if (!diff.trim()) {
               diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
                   cwd: Instance.directory,
                 })
               ).text()
             }
             if (diff.trim()) {
-              const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
+              const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
               const patch = structuredPatch(file, file, original, content, "old", "new", {
                 context: Infinity,
                 ignoreWhitespace: true,
@@ -688,7 +676,7 @@ export namespace File {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function init() {
     return runPromise((svc) => svc.init())

+ 2 - 2
packages/opencode/src/file/time.ts

@@ -1,6 +1,6 @@
 import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
 import { Filesystem } from "../util/filesystem"
@@ -108,7 +108,7 @@ export namespace FileTime {
     }),
   ).pipe(Layer.orDie)
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function read(sessionID: SessionID, file: string) {
     return runPromise((s) => s.read(sessionID, file))

+ 4 - 4
packages/opencode/src/file/watcher.ts

@@ -8,10 +8,10 @@ import z from "zod"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
+import { Git } from "@/git"
 import { Instance } from "@/project/instance"
-import { git } from "@/util/git"
 import { lazy } from "@/util/lazy"
 import { Config } from "../config/config"
 import { FileIgnore } from "./ignore"
@@ -130,7 +130,7 @@ export namespace FileWatcher {
 
             if (Instance.project.vcs === "git") {
               const result = yield* Effect.promise(() =>
-                git(["rev-parse", "--git-dir"], {
+                Git.run(["rev-parse", "--git-dir"], {
                   cwd: Instance.project.worktree,
                 }),
               )
@@ -159,7 +159,7 @@ export namespace FileWatcher {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export function init() {
     return runPromise((svc) => svc.init())

+ 48 - 47
packages/opencode/src/format/index.ts

@@ -1,12 +1,10 @@
 import { Effect, Layer, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import path from "path"
 import { mergeDeep } from "remeda"
 import z from "zod"
-import { Bus } from "../bus"
 import { Config } from "../config/config"
-import { File } from "../file"
 import { Instance } from "../project/instance"
 import { Process } from "../util/process"
 import { Log } from "../util/log"
@@ -29,6 +27,7 @@ export namespace Format {
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly status: () => Effect.Effect<Status[]>
+    readonly file: (filepath: string) => Effect.Effect<void>
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
@@ -97,53 +96,46 @@ export namespace Format {
             return checks.filter((x) => x.enabled).map((x) => x.item)
           }
 
-          yield* Effect.acquireRelease(
-            Effect.sync(() =>
-              Bus.subscribe(
-                File.Event.Edited,
-                Instance.bind(async (payload) => {
-                  const file = payload.properties.file
-                  log.info("formatting", { file })
-                  const ext = path.extname(file)
-
-                  for (const item of await getFormatter(ext)) {
-                    log.info("running", { command: item.command })
-                    try {
-                      const proc = Process.spawn(
-                        item.command.map((x) => x.replace("$FILE", file)),
-                        {
-                          cwd: Instance.directory,
-                          env: { ...process.env, ...item.environment },
-                          stdout: "ignore",
-                          stderr: "ignore",
-                        },
-                      )
-                      const exit = await proc.exited
-                      if (exit !== 0) {
-                        log.error("failed", {
-                          command: item.command,
-                          ...item.environment,
-                        })
-                      }
-                    } catch (error) {
-                      log.error("failed to format file", {
-                        error,
-                        command: item.command,
-                        ...item.environment,
-                        file,
-                      })
-                    }
-                  }
-                }),
-              ),
-            ),
-            (unsubscribe) => Effect.sync(unsubscribe),
-          )
+          async function formatFile(filepath: string) {
+            log.info("formatting", { file: filepath })
+            const ext = path.extname(filepath)
+
+            for (const item of await getFormatter(ext)) {
+              log.info("running", { command: item.command })
+              try {
+                const proc = Process.spawn(
+                  item.command.map((x) => x.replace("$FILE", filepath)),
+                  {
+                    cwd: Instance.directory,
+                    env: { ...process.env, ...item.environment },
+                    stdout: "ignore",
+                    stderr: "ignore",
+                  },
+                )
+                const exit = await proc.exited
+                if (exit !== 0) {
+                  log.error("failed", {
+                    command: item.command,
+                    ...item.environment,
+                  })
+                }
+              } catch (error) {
+                log.error("failed to format file", {
+                  error,
+                  command: item.command,
+                  ...item.environment,
+                  file: filepath,
+                })
+              }
+            }
+          }
+
           log.info("init")
 
           return {
             formatters,
             isEnabled,
+            formatFile,
           }
         }),
       )
@@ -166,11 +158,16 @@ export namespace Format {
         return result
       })
 
-      return Service.of({ init, status })
+      const file = Effect.fn("Format.file")(function* (filepath: string) {
+        const { formatFile } = yield* InstanceState.get(state)
+        yield* Effect.promise(() => formatFile(filepath))
+      })
+
+      return Service.of({ init, status, file })
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function init() {
     return runPromise((s) => s.init())
@@ -179,4 +176,8 @@ export namespace Format {
   export async function status() {
     return runPromise((s) => s.status())
   }
+
+  export async function file(filepath: string) {
+    return runPromise((s) => s.file(filepath))
+  }
 }

+ 308 - 0
packages/opencode/src/git/index.ts

@@ -0,0 +1,308 @@
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { makeRuntime } from "@/effect/run-service"
+
+export namespace Git {
+  const cfg = [
+    "--no-optional-locks",
+    "-c",
+    "core.autocrlf=false",
+    "-c",
+    "core.fsmonitor=false",
+    "-c",
+    "core.longpaths=true",
+    "-c",
+    "core.symlinks=true",
+    "-c",
+    "core.quotepath=false",
+  ] as const
+
+  const out = (result: { text(): string }) => result.text().trim()
+  const nuls = (text: string) => text.split("\0").filter(Boolean)
+  const fail = (err: unknown) =>
+    ({
+      exitCode: 1,
+      text: () => "",
+      stdout: Buffer.alloc(0),
+      stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
+    }) satisfies Result
+
+  export type Kind = "added" | "deleted" | "modified"
+
+  export type Base = {
+    readonly name: string
+    readonly ref: string
+  }
+
+  export type Item = {
+    readonly file: string
+    readonly code: string
+    readonly status: Kind
+  }
+
+  export type Stat = {
+    readonly file: string
+    readonly additions: number
+    readonly deletions: number
+  }
+
+  export interface Result {
+    readonly exitCode: number
+    readonly text: () => string
+    readonly stdout: Buffer
+    readonly stderr: Buffer
+  }
+
+  export interface Options {
+    readonly cwd: string
+    readonly env?: Record<string, string>
+  }
+
+  export interface Interface {
+    readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
+    readonly branch: (cwd: string) => Effect.Effect<string | undefined>
+    readonly prefix: (cwd: string) => Effect.Effect<string>
+    readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
+    readonly hasHead: (cwd: string) => Effect.Effect<boolean>
+    readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
+    readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
+    readonly status: (cwd: string) => Effect.Effect<Item[]>
+    readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
+    readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
+  }
+
+  const kind = (code: string): Kind => {
+    if (code === "??") return "added"
+    if (code.includes("U")) return "modified"
+    if (code.includes("A") && !code.includes("D")) return "added"
+    if (code.includes("D") && !code.includes("A")) return "deleted"
+    return "modified"
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+
+      const run = Effect.fn("Git.run")(
+        function* (args: string[], opts: Options) {
+          const proc = ChildProcess.make("git", [...cfg, ...args], {
+            cwd: opts.cwd,
+            env: opts.env,
+            extendEnv: true,
+            stdin: "ignore",
+            stdout: "pipe",
+            stderr: "pipe",
+          })
+          const handle = yield* spawner.spawn(proc)
+          const [stdout, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          return {
+            exitCode: yield* handle.exitCode,
+            text: () => stdout,
+            stdout: Buffer.from(stdout),
+            stderr: Buffer.from(stderr),
+          } satisfies Result
+        },
+        Effect.scoped,
+        Effect.catch((err) => Effect.succeed(fail(err))),
+      )
+
+      const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
+        return (yield* run(args, opts)).text()
+      })
+
+      const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
+        return (yield* text(args, opts))
+          .split(/\r?\n/)
+          .map((item) => item.trim())
+          .filter(Boolean)
+      })
+
+      const refs = Effect.fnUntraced(function* (cwd: string) {
+        return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
+      })
+
+      const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
+        const result = yield* run(["config", "init.defaultBranch"], { cwd })
+        const name = out(result)
+        if (!name || !list.includes(name)) return
+        return { name, ref: name } satisfies Base
+      })
+
+      const primary = Effect.fnUntraced(function* (cwd: string) {
+        const list = yield* lines(["remote"], { cwd })
+        if (list.includes("origin")) return "origin"
+        if (list.length === 1) return list[0]
+        if (list.includes("upstream")) return "upstream"
+        return list[0]
+      })
+
+      const branch = Effect.fn("Git.branch")(function* (cwd: string) {
+        const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
+        if (result.exitCode !== 0) return
+        const text = out(result)
+        return text || undefined
+      })
+
+      const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
+        const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
+        if (result.exitCode !== 0) return ""
+        return out(result)
+      })
+
+      const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
+        const remote = yield* primary(cwd)
+        if (remote) {
+          const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
+          if (head.exitCode === 0) {
+            const ref = out(head).replace(/^refs\/remotes\//, "")
+            const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
+            if (name) return { name, ref } satisfies Base
+          }
+        }
+
+        const list = yield* refs(cwd)
+        const next = yield* configured(cwd, list)
+        if (next) return next
+        if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
+        if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
+      })
+
+      const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
+        const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
+        return result.exitCode === 0
+      })
+
+      const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
+        const result = yield* run(["merge-base", base, head], { cwd })
+        if (result.exitCode !== 0) return
+        const text = out(result)
+        return text || undefined
+      })
+
+      const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
+        const target = prefix ? `${prefix}${file}` : file
+        const result = yield* run(["show", `${ref}:${target}`], { cwd })
+        if (result.exitCode !== 0) return ""
+        if (result.stdout.includes(0)) return ""
+        return result.text()
+      })
+
+      const status = Effect.fn("Git.status")(function* (cwd: string) {
+        return nuls(
+          yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
+            cwd,
+          }),
+        ).flatMap((item) => {
+          const file = item.slice(3)
+          if (!file) return []
+          const code = item.slice(0, 2)
+          return [{ file, code, status: kind(code) } satisfies Item]
+        })
+      })
+
+      const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
+        const list = nuls(
+          yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
+        )
+        return list.flatMap((code, idx) => {
+          if (idx % 2 !== 0) return []
+          const file = list[idx + 1]
+          if (!code || !file) return []
+          return [{ file, code, status: kind(code) } satisfies Item]
+        })
+      })
+
+      const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
+        return nuls(
+          yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
+        ).flatMap((item) => {
+          const a = item.indexOf("\t")
+          const b = item.indexOf("\t", a + 1)
+          if (a === -1 || b === -1) return []
+          const file = item.slice(b + 1)
+          if (!file) return []
+          const adds = item.slice(0, a)
+          const dels = item.slice(a + 1, b)
+          const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
+          const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
+          return [
+            {
+              file,
+              additions: Number.isFinite(additions) ? additions : 0,
+              deletions: Number.isFinite(deletions) ? deletions : 0,
+            } satisfies Stat,
+          ]
+        })
+      })
+
+      return Service.of({
+        run,
+        branch,
+        prefix,
+        defaultBranch,
+        hasHead,
+        mergeBase,
+        show,
+        status,
+        diff,
+        stats,
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export function run(args: string[], opts: Options) {
+    return runPromise((git) => git.run(args, opts))
+  }
+
+  export function branch(cwd: string) {
+    return runPromise((git) => git.branch(cwd))
+  }
+
+  export function prefix(cwd: string) {
+    return runPromise((git) => git.prefix(cwd))
+  }
+
+  export function defaultBranch(cwd: string) {
+    return runPromise((git) => git.defaultBranch(cwd))
+  }
+
+  export function hasHead(cwd: string) {
+    return runPromise((git) => git.hasHead(cwd))
+  }
+
+  export function mergeBase(cwd: string, base: string, head?: string) {
+    return runPromise((git) => git.mergeBase(cwd, base, head))
+  }
+
+  export function show(cwd: string, ref: string, file: string, prefix?: string) {
+    return runPromise((git) => git.show(cwd, ref, file, prefix))
+  }
+
+  export function status(cwd: string) {
+    return runPromise((git) => git.status(cwd))
+  }
+
+  export function diff(cwd: string, ref: string) {
+    return runPromise((git) => git.diff(cwd, ref))
+  }
+
+  export function stats(cwd: string, ref: string) {
+    return runPromise((git) => git.stats(cwd, ref))
+  }
+}

+ 1 - 0
packages/opencode/src/id/id.ts

@@ -3,6 +3,7 @@ import { randomBytes } from "crypto"
 
 export namespace Identifier {
   const prefixes = {
+    event: "evt",
     session: "ses",
     message: "msg",
     permission: "per",

+ 2 - 2
packages/opencode/src/installation/index.ts

@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
@@ -346,7 +346,7 @@ export namespace Installation {
     Layer.provide(NodePath.layer),
   )
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function info(): Promise<Info> {
     return runPromise((svc) => svc.info())

+ 6 - 0
packages/opencode/src/lsp/index.ts

@@ -177,6 +177,12 @@ export namespace LSP {
 
   async function getClients(file: string) {
     const s = await state()
+
+    // Only spawn LSP clients for files within the instance directory
+    if (!Instance.containsPath(file)) {
+      return []
+    }
+
     const extension = path.parse(file).ext || file
     const result: LSPClient.Info[] = []
 

+ 147 - 96
packages/opencode/src/mcp/auth.ts

@@ -1,7 +1,9 @@
 import path from "path"
 import z from "zod"
 import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
+import { Effect, Layer, ServiceMap } from "effect"
+import { AppFileSystem } from "@/filesystem"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace McpAuth {
   export const Tokens = z.object({
@@ -25,106 +27,155 @@ export namespace McpAuth {
     clientInfo: ClientInfo.optional(),
     codeVerifier: z.string().optional(),
     oauthState: z.string().optional(),
-    serverUrl: z.string().optional(), // Track the URL these credentials are for
+    serverUrl: z.string().optional(),
   })
   export type Entry = z.infer<typeof Entry>
 
   const filepath = path.join(Global.Path.data, "mcp-auth.json")
 
-  export async function get(mcpName: string): Promise<Entry | undefined> {
-    const data = await all()
-    return data[mcpName]
+  export interface Interface {
+    readonly all: () => Effect.Effect<Record<string, Entry>>
+    readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
+    readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
+    readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
+    readonly remove: (mcpName: string) => Effect.Effect<void>
+    readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
+    readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
+    readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
+    readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
+    readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
+    readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
+    readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
+    readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
   }
 
-  /**
-   * Get auth entry and validate it's for the correct URL.
-   * Returns undefined if URL has changed (credentials are invalid).
-   */
-  export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
-    const entry = await get(mcpName)
-    if (!entry) return undefined
-
-    // If no serverUrl is stored, this is from an old version - consider it invalid
-    if (!entry.serverUrl) return undefined
-
-    // If URL has changed, credentials are invalid
-    if (entry.serverUrl !== serverUrl) return undefined
-
-    return entry
-  }
-
-  export async function all(): Promise<Record<string, Entry>> {
-    return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
-  }
-
-  export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
-    const data = await all()
-    // Always update serverUrl if provided
-    if (serverUrl) {
-      entry.serverUrl = serverUrl
-    }
-    await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
-  }
-
-  export async function remove(mcpName: string): Promise<void> {
-    const data = await all()
-    delete data[mcpName]
-    await Filesystem.writeJson(filepath, data, 0o600)
-  }
-
-  export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.tokens = tokens
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.clientInfo = clientInfo
-    await set(mcpName, entry, serverUrl)
-  }
-
-  export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.codeVerifier = codeVerifier
-    await set(mcpName, entry)
-  }
-
-  export async function clearCodeVerifier(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.codeVerifier
-      await set(mcpName, entry)
-    }
-  }
-
-  export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
-    const entry = (await get(mcpName)) ?? {}
-    entry.oauthState = oauthState
-    await set(mcpName, entry)
-  }
-
-  export async function getOAuthState(mcpName: string): Promise<string | undefined> {
-    const entry = await get(mcpName)
-    return entry?.oauthState
-  }
-
-  export async function clearOAuthState(mcpName: string): Promise<void> {
-    const entry = await get(mcpName)
-    if (entry) {
-      delete entry.oauthState
-      await set(mcpName, entry)
-    }
-  }
-
-  /**
-   * Check if stored tokens are expired.
-   * Returns null if no tokens exist, false if no expiry or not expired, true if expired.
-   */
-  export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
-    const entry = await get(mcpName)
-    if (!entry?.tokens) return null
-    if (!entry.tokens.expiresAt) return false
-    return entry.tokens.expiresAt < Date.now() / 1000
-  }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+
+      const all = Effect.fn("McpAuth.all")(function* () {
+        return yield* fs.readJson(filepath).pipe(
+          Effect.map((data) => data as Record<string, Entry>),
+          Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
+        )
+      })
+
+      const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
+        const data = yield* all()
+        return data[mcpName]
+      })
+
+      const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
+        const entry = yield* get(mcpName)
+        if (!entry) return undefined
+        if (!entry.serverUrl) return undefined
+        if (entry.serverUrl !== serverUrl) return undefined
+        return entry
+      })
+
+      const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
+        const data = yield* all()
+        if (serverUrl) entry.serverUrl = serverUrl
+        yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
+      })
+
+      const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
+        const data = yield* all()
+        delete data[mcpName]
+        yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
+      })
+
+      const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
+        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
+          const entry = (yield* get(mcpName)) ?? {}
+          entry[field] = value
+          yield* set(mcpName, entry, serverUrl)
+        })
+
+      const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
+        Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
+          const entry = yield* get(mcpName)
+          if (entry) {
+            delete entry[field]
+            yield* set(mcpName, entry)
+          }
+        })
+
+      const updateTokens = updateField("tokens", "updateTokens")
+      const updateClientInfo = updateField("clientInfo", "updateClientInfo")
+      const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
+      const updateOAuthState = updateField("oauthState", "updateOAuthState")
+      const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
+      const clearOAuthState = clearField("oauthState", "clearOAuthState")
+
+      const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
+        const entry = yield* get(mcpName)
+        return entry?.oauthState
+      })
+
+      const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
+        const entry = yield* get(mcpName)
+        if (!entry?.tokens) return null
+        if (!entry.tokens.expiresAt) return false
+        return entry.tokens.expiresAt < Date.now() / 1000
+      })
+
+      return Service.of({
+        all,
+        get,
+        getForUrl,
+        set,
+        remove,
+        updateTokens,
+        updateClientInfo,
+        updateCodeVerifier,
+        clearCodeVerifier,
+        updateOAuthState,
+        getOAuthState,
+        clearOAuthState,
+        isTokenExpired,
+      })
+    }),
+  )
+
+  const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  // Async facades for backward compat (used by McpOAuthProvider, CLI)
+
+  export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
+
+  export const getForUrl = async (mcpName: string, serverUrl: string) =>
+    runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
+
+  export const all = async () => runPromise((svc) => svc.all())
+
+  export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
+    runPromise((svc) => svc.set(mcpName, entry, serverUrl))
+
+  export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
+
+  export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
+    runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
+
+  export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
+    runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
+
+  export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
+    runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
+
+  export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
+
+  export const updateOAuthState = async (mcpName: string, oauthState: string) =>
+    runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
+
+  export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
+
+  export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
+
+  export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
 }

+ 519 - 560
packages/opencode/src/mcp/index.ts

@@ -11,12 +11,12 @@ import {
 } from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
-import { Process } from "../util/process"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
 import { Installation } from "../installation"
 import { withTimeout } from "@/util/timeout"
+import { AppFileSystem } from "@/filesystem"
 import { McpOAuthProvider } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
@@ -24,6 +24,13 @@ import { BusEvent } from "../bus/bus-event"
 import { Bus } from "@/bus"
 import { TuiEvent } from "@/cli/cmd/tui/event"
 import open from "open"
+import { Effect, Layer, Option, ServiceMap, Stream } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { NodeFileSystem } from "@effect/platform-node"
+import * as NodePath from "@effect/platform-node/NodePath"
 
 export namespace MCP {
   const log = Log.create({ service: "mcp" })
@@ -109,16 +116,21 @@ export namespace MCP {
     })
   export type Status = z.infer<typeof Status>
 
-  // Register notification handlers for MCP client
-  function registerNotificationHandlers(client: MCPClient, serverName: string) {
-    client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
-      log.info("tools list changed notification received", { server: serverName })
-      Bus.publish(ToolsChanged, { server: serverName })
-    })
+  // Store transports for OAuth servers to allow finishing auth
+  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
+  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
+
+  // Prompt cache types
+  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
+  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
+  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
+
+  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
+    return typeof entry === "object" && entry !== null && "type" in entry
   }
 
   // Convert MCP tool definition to AI SDK Tool type
-  async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Promise<Tool> {
+  function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
     const inputSchema = mcpTool.inputSchema
 
     // Spread first, then override type to ensure it's always "object"
@@ -148,178 +160,33 @@ export namespace MCP {
     })
   }
 
-  // Store transports for OAuth servers to allow finishing auth
-  type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
-  const pendingOAuthTransports = new Map<string, TransportWithAuth>()
-
-  // Prompt cache types
-  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
-
-  type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
-  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
-  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
-    return typeof entry === "object" && entry !== null && "type" in entry
-  }
-
-  async function descendants(pid: number): Promise<number[]> {
-    if (process.platform === "win32") return []
-    const pids: number[] = []
-    const queue = [pid]
-    while (queue.length > 0) {
-      const current = queue.shift()!
-      const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
-      for (const tok of lines) {
-        const cpid = parseInt(tok, 10)
-        if (!isNaN(cpid) && !pids.includes(cpid)) {
-          pids.push(cpid)
-          queue.push(cpid)
-        }
-      }
-    }
-    return pids
-  }
-
-  const state = Instance.state(
-    async () => {
-      const cfg = await Config.get()
-      const config = cfg.mcp ?? {}
-      const clients: Record<string, MCPClient> = {}
-      const status: Record<string, Status> = {}
-
-      await Promise.all(
-        Object.entries(config).map(async ([key, mcp]) => {
-          if (!isMcpConfigured(mcp)) {
-            log.error("Ignoring MCP config entry without type", { key })
-            return
-          }
-
-          // If disabled by config, mark as disabled without trying to connect
-          if (mcp.enabled === false) {
-            status[key] = { status: "disabled" }
-            return
-          }
-
-          const result = await create(key, mcp).catch(() => undefined)
-          if (!result) return
-
-          status[key] = result.status
-
-          if (result.mcpClient) {
-            clients[key] = result.mcpClient
-          }
-        }),
-      )
-      return {
-        status,
-        clients,
-      }
-    },
-    async (state) => {
-      // The MCP SDK only signals the direct child process on close.
-      // Servers like chrome-devtools-mcp spawn grandchild processes
-      // (e.g. Chrome) that the SDK never reaches, leaving them orphaned.
-      // Kill the full descendant tree first so the server exits promptly
-      // and no processes are left behind.
-      for (const client of Object.values(state.clients)) {
-        const pid = (client.transport as any)?.pid
-        if (typeof pid !== "number") continue
-        for (const dpid of await descendants(pid)) {
-          try {
-            process.kill(dpid, "SIGTERM")
-          } catch {}
-        }
-      }
-
-      await Promise.all(
-        Object.values(state.clients).map((client) =>
-          client.close().catch((error) => {
-            log.error("Failed to close MCP client", {
-              error,
-            })
-          }),
-        ),
-      )
-      pendingOAuthTransports.clear()
-    },
-  )
-
-  // Helper function to fetch prompts for a specific client
-  async function fetchPromptsForClient(clientName: string, client: Client) {
-    const prompts = await client.listPrompts().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function defs(key: string, client: MCPClient, timeout?: number) {
+    const result = await withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT).catch((err) => {
+      log.error("failed to get tools from client", { key, error: err })
       return undefined
     })
-
-    if (!prompts) {
-      return
-    }
-
-    const commands: Record<string, PromptInfo & { client: string }> = {}
-
-    for (const prompt of prompts.prompts) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedPromptName
-
-      commands[key] = { ...prompt, client: clientName }
-    }
-    return commands
+    return result?.tools
   }
 
-  async function fetchResourcesForClient(clientName: string, client: Client) {
-    const resources = await client.listResources().catch((e) => {
-      log.error("failed to get prompts", { clientName, error: e.message })
+  async function fetchFromClient<T extends { name: string }>(
+    clientName: string,
+    client: Client,
+    listFn: (c: Client) => Promise<T[]>,
+    label: string,
+  ): Promise<Record<string, T & { client: string }> | undefined> {
+    const items = await listFn(client).catch((e: any) => {
+      log.error(`failed to get ${label}`, { clientName, error: e.message })
       return undefined
     })
+    if (!items) return undefined
 
-    if (!resources) {
-      return
-    }
-
-    const commands: Record<string, ResourceInfo & { client: string }> = {}
-
-    for (const resource of resources.resources) {
-      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const sanitizedResourceName = resource.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-      const key = sanitizedClientName + ":" + sanitizedResourceName
-
-      commands[key] = { ...resource, client: clientName }
-    }
-    return commands
-  }
-
-  export async function add(name: string, mcp: Config.Mcp) {
-    const s = await state()
-    const result = await create(name, mcp)
-    if (!result) {
-      const status = {
-        status: "failed" as const,
-        error: "unknown error",
-      }
-      s.status[name] = status
-      return {
-        status,
-      }
-    }
-    if (!result.mcpClient) {
-      s.status[name] = result.status
-      return {
-        status: s.status,
-      }
-    }
-    // Close existing client if present to prevent memory leaks
-    const existingClient = s.clients[name]
-    if (existingClient) {
-      await existingClient.close().catch((error) => {
-        log.error("Failed to close existing MCP client", { name, error })
-      })
-    }
-    s.clients[name] = result.mcpClient
-    s.status[name] = result.status
-
-    return {
-      status: s.status,
+    const out: Record<string, T & { client: string }> = {}
+    const sanitizedClient = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+    for (const item of items) {
+      const sanitizedName = item.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+      out[sanitizedClient + ":" + sanitizedName] = { ...item, client: clientName }
     }
+    return out
   }
 
   async function create(key: string, mcp: Config.Mcp) {
@@ -385,7 +252,6 @@ export namespace MCP {
             version: Installation.VERSION,
           })
           await withTimeout(client.connect(transport), connectTimeout)
-          registerNotificationHandlers(client, key)
           mcpClient = client
           log.info("connected", { key, transport: name })
           status = { status: "connected" }
@@ -470,7 +336,6 @@ export namespace MCP {
           version: Installation.VERSION,
         })
         await withTimeout(client.connect(transport), connectTimeout)
-        registerNotificationHandlers(client, key)
         mcpClient = client
         status = {
           status: "connected",
@@ -503,475 +368,569 @@ export namespace MCP {
       }
     }
 
-    const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
-      log.error("failed to get tools from client", { key, error: err })
-      return undefined
-    })
-    if (!result) {
+    const listed = await defs(key, mcpClient, mcp.timeout)
+    if (!listed) {
       await mcpClient.close().catch((error) => {
         log.error("Failed to close MCP client", {
           error,
         })
       })
-      status = {
-        status: "failed",
-        error: "Failed to get tools",
-      }
       return {
         mcpClient: undefined,
-        status: {
-          status: "failed" as const,
-          error: "Failed to get tools",
-        },
+        status: { status: "failed" as const, error: "Failed to get tools" },
       }
     }
 
-    log.info("create() successfully created client", { key, toolCount: result.tools.length })
+    log.info("create() successfully created client", { key, toolCount: listed.length })
     return {
       mcpClient,
       status,
+      defs: listed,
     }
   }
 
-  export async function status() {
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const result: Record<string, Status> = {}
+  // --- Effect Service ---
 
-    // Include all configured MCPs from config, not just connected ones
-    for (const [key, mcp] of Object.entries(config)) {
-      if (!isMcpConfigured(mcp)) continue
-      result[key] = s.status[key] ?? { status: "disabled" }
-    }
-
-    return result
+  interface State {
+    status: Record<string, Status>
+    clients: Record<string, MCPClient>
+    defs: Record<string, MCPToolDef[]>
   }
 
-  export async function clients() {
-    return state().then((state) => state.clients)
+  export interface Interface {
+    readonly status: () => Effect.Effect<Record<string, Status>>
+    readonly clients: () => Effect.Effect<Record<string, MCPClient>>
+    readonly tools: () => Effect.Effect<Record<string, Tool>>
+    readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
+    readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
+    readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record<string, Status> | Status }>
+    readonly connect: (name: string) => Effect.Effect<void>
+    readonly disconnect: (name: string) => Effect.Effect<void>
+    readonly getPrompt: (
+      clientName: string,
+      name: string,
+      args?: Record<string, string>,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
+    readonly readResource: (
+      clientName: string,
+      resourceUri: string,
+    ) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
+    readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
+    readonly authenticate: (mcpName: string) => Effect.Effect<Status>
+    readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status>
+    readonly removeAuth: (mcpName: string) => Effect.Effect<void>
+    readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean>
+    readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
+    readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
   }
 
-  export async function connect(name: string) {
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const mcp = config[name]
-    if (!mcp) {
-      log.error("MCP config not found", { name })
-      return
-    }
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const auth = yield* McpAuth.Service
+
+      const descendants = Effect.fnUntraced(
+        function* (pid: number) {
+          if (process.platform === "win32") return [] as number[]
+          const pids: number[] = []
+          const queue = [pid]
+          while (queue.length > 0) {
+            const current = queue.shift()!
+            const handle = yield* spawner.spawn(
+              ChildProcess.make("pgrep", ["-P", String(current)], { stdin: "ignore" }),
+            )
+            const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
+            yield* handle.exitCode
+            for (const tok of text.split("\n")) {
+              const cpid = parseInt(tok, 10)
+              if (!isNaN(cpid) && !pids.includes(cpid)) {
+                pids.push(cpid)
+                queue.push(cpid)
+              }
+            }
+          }
+          return pids
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed([] as number[])),
+      )
 
-    if (!isMcpConfigured(mcp)) {
-      log.error("Ignoring MCP connect request for config without type", { name })
-      return
-    }
+      function watch(s: State, name: string, client: MCPClient, timeout?: number) {
+        client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
+          log.info("tools list changed notification received", { server: name })
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    const result = await create(name, { ...mcp, enabled: true })
+          const listed = await defs(name, client, timeout)
+          if (!listed) return
+          if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
 
-    if (!result) {
-      const s = await state()
-      s.status[name] = {
-        status: "failed",
-        error: "Unknown error during connection",
+          s.defs[name] = listed
+          await Bus.publish(ToolsChanged, { server: name }).catch((error) =>
+            log.warn("failed to publish tools changed", { server: name, error }),
+          )
+        })
       }
-      return
-    }
 
-    const s = await state()
-    s.status[name] = result.status
-    if (result.mcpClient) {
-      // Close existing client if present to prevent memory leaks
-      const existingClient = s.clients[name]
-      if (existingClient) {
-        await existingClient.close().catch((error) => {
-          log.error("Failed to close existing MCP client", { name, error })
-        })
+      const cache = yield* InstanceState.make<State>(
+        Effect.fn("MCP.state")(function* () {
+          const cfg = yield* Effect.promise(() => Config.get())
+          const config = cfg.mcp ?? {}
+          const s: State = {
+            status: {},
+            clients: {},
+            defs: {},
+          }
+
+          yield* Effect.forEach(
+            Object.entries(config),
+            ([key, mcp]) =>
+              Effect.gen(function* () {
+                if (!isMcpConfigured(mcp)) {
+                  log.error("Ignoring MCP config entry without type", { key })
+                  return
+                }
+
+                if (mcp.enabled === false) {
+                  s.status[key] = { status: "disabled" }
+                  return
+                }
+
+                const result = yield* Effect.promise(() => create(key, mcp).catch(() => undefined))
+                if (!result) return
+
+                s.status[key] = result.status
+                if (result.mcpClient) {
+                  s.clients[key] = result.mcpClient
+                  s.defs[key] = result.defs
+                  watch(s, key, result.mcpClient, mcp.timeout)
+                }
+              }),
+            { concurrency: "unbounded" },
+          )
+
+          yield* Effect.addFinalizer(() =>
+            Effect.gen(function* () {
+              yield* Effect.forEach(
+                Object.values(s.clients),
+                (client) =>
+                  Effect.gen(function* () {
+                    const pid = (client.transport as any)?.pid
+                    if (typeof pid === "number") {
+                      const pids = yield* descendants(pid)
+                      for (const dpid of pids) {
+                        try {
+                          process.kill(dpid, "SIGTERM")
+                        } catch {}
+                      }
+                    }
+                    yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
+                  }),
+                { concurrency: "unbounded" },
+              )
+              pendingOAuthTransports.clear()
+            }),
+          )
+
+          return s
+        }),
+      )
+
+      function closeClient(s: State, name: string) {
+        const client = s.clients[name]
+        delete s.defs[name]
+        if (!client) return Effect.void
+        return Effect.promise(() =>
+          client.close().catch((error: any) => log.error("failed to close MCP client", { name, error })),
+        )
       }
-      s.clients[name] = result.mcpClient
-    }
-  }
 
-  export async function disconnect(name: string) {
-    const s = await state()
-    const client = s.clients[name]
-    if (client) {
-      await client.close().catch((error) => {
-        log.error("Failed to close MCP client", { name, error })
+      const status = Effect.fn("MCP.status")(function* () {
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const result: Record<string, Status> = {}
+
+        for (const [key, mcp] of Object.entries(config)) {
+          if (!isMcpConfigured(mcp)) continue
+          result[key] = s.status[key] ?? { status: "disabled" }
+        }
+
+        return result
       })
-      delete s.clients[name]
-    }
-    s.status[name] = { status: "disabled" }
-  }
 
-  export async function tools() {
-    const result: Record<string, Tool> = {}
-    const s = await state()
-    const cfg = await Config.get()
-    const config = cfg.mcp ?? {}
-    const clientsSnapshot = await clients()
-    const defaultTimeout = cfg.experimental?.mcp_timeout
-
-    const connectedClients = Object.entries(clientsSnapshot).filter(
-      ([clientName]) => s.status[clientName]?.status === "connected",
-    )
-
-    const toolsResults = await Promise.all(
-      connectedClients.map(async ([clientName, client]) => {
-        const toolsResult = await client.listTools().catch((e) => {
-          log.error("failed to get tools", { clientName, error: e.message })
-          const failedStatus = {
-            status: "failed" as const,
-            error: e instanceof Error ? e.message : String(e),
-          }
-          s.status[clientName] = failedStatus
-          delete s.clients[clientName]
-          return undefined
-        })
-        return { clientName, client, toolsResult }
-      }),
-    )
-
-    for (const { clientName, client, toolsResult } of toolsResults) {
-      if (!toolsResult) continue
-      const mcpConfig = config[clientName]
-      const entry = isMcpConfigured(mcpConfig) ? mcpConfig : undefined
-      const timeout = entry?.timeout ?? defaultTimeout
-      for (const mcpTool of toolsResult.tools) {
-        const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-        const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-        result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client, timeout)
-      }
-    }
-    return result
-  }
+      const clients = Effect.fn("MCP.clients")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return s.clients
+      })
 
-  export async function prompts() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
+        const s = yield* InstanceState.get(cache)
+        const result = yield* Effect.promise(() => create(name, mcp))
 
-    const prompts = Object.fromEntries<PromptInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+        if (!result) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          s.status[name] = { status: "failed" as const, error: "unknown error" }
+          return s.status[name]
+        }
 
-            return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
-          }),
-        )
-      ).flat(),
-    )
+        s.status[name] = result.status
+        if (!result.mcpClient) {
+          yield* closeClient(s, name)
+          delete s.clients[name]
+          return result.status
+        }
 
-    return prompts
-  }
+        yield* closeClient(s, name)
+        s.clients[name] = result.mcpClient
+        s.defs[name] = result.defs
+        watch(s, name, result.mcpClient, mcp.timeout)
+        return result.status
+      })
 
-  export async function resources() {
-    const s = await state()
-    const clientsSnapshot = await clients()
+      const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
+        yield* createAndStore(name, mcp)
+        const s = yield* InstanceState.get(cache)
+        return { status: s.status }
+      })
 
-    const result = Object.fromEntries<ResourceInfo & { client: string }>(
-      (
-        await Promise.all(
-          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
-            if (s.status[clientName]?.status !== "connected") {
-              return []
-            }
+      const connect = Effect.fn("MCP.connect")(function* (name: string) {
+        const mcp = yield* getMcpConfig(name)
+        if (!mcp) {
+          log.error("MCP config not found or invalid", { name })
+          return
+        }
+        yield* createAndStore(name, { ...mcp, enabled: true })
+      })
 
-            return Object.entries((await fetchResourcesForClient(clientName, client)) ?? {})
-          }),
+      const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
+        const s = yield* InstanceState.get(cache)
+        yield* closeClient(s, name)
+        delete s.clients[name]
+        s.status[name] = { status: "disabled" }
+      })
+
+      const tools = Effect.fn("MCP.tools")(function* () {
+        const result: Record<string, Tool> = {}
+        const s = yield* InstanceState.get(cache)
+        const cfg = yield* Effect.promise(() => Config.get())
+        const config = cfg.mcp ?? {}
+        const defaultTimeout = cfg.experimental?.mcp_timeout
+
+        const connectedClients = Object.entries(s.clients).filter(
+          ([clientName]) => s.status[clientName]?.status === "connected",
         )
-      ).flat(),
-    )
 
-    return result
-  }
+        yield* Effect.forEach(
+          connectedClients,
+          ([clientName, client]) =>
+            Effect.gen(function* () {
+              const mcpConfig = config[clientName]
+              const entry = mcpConfig && isMcpConfigured(mcpConfig) ? mcpConfig : undefined
+
+              const listed = s.defs[clientName]
+              if (!listed) {
+                log.warn("missing cached tools for connected server", { clientName })
+                return
+              }
+
+              const timeout = entry?.timeout ?? defaultTimeout
+              for (const mcpTool of listed) {
+                const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+                const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+                result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client, timeout)
+              }
+            }),
+          { concurrency: "unbounded" },
+        )
+        return result
+      })
 
-  export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
+      function collectFromConnected<T>(
+        s: State,
+        fetchFn: (clientName: string, client: Client) => Promise<Record<string, T> | undefined>,
+      ) {
+        return Effect.forEach(
+          Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
+          ([clientName, client]) =>
+            Effect.promise(async () => Object.entries((await fetchFn(clientName, client)) ?? {})),
+          { concurrency: "unbounded" },
+        ).pipe(Effect.map((results) => Object.fromEntries<T>(results.flat())))
+      }
 
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName,
+      const prompts = Effect.fn("MCP.prompts")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listPrompts().then((r) => r.prompts), "prompts"),
+        )
       })
-      return undefined
-    }
 
-    const result = await client
-      .getPrompt({
-        name: name,
-        arguments: args,
+      const resources = Effect.fn("MCP.resources")(function* () {
+        const s = yield* InstanceState.get(cache)
+        return yield* collectFromConnected(s, (name, client) =>
+          fetchFromClient(name, client, (c) => c.listResources().then((r) => r.resources), "resources"),
+        )
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName,
+
+      const withClient = Effect.fnUntraced(function* <A>(
+        clientName: string,
+        fn: (client: MCPClient) => Promise<A>,
+        label: string,
+        meta?: Record<string, unknown>,
+      ) {
+        const s = yield* InstanceState.get(cache)
+        const client = s.clients[clientName]
+        if (!client) {
+          log.warn(`client not found for ${label}`, { clientName })
+          return undefined
+        }
+        return yield* Effect.tryPromise({
+          try: () => fn(client),
+          catch: (e: any) => {
+            log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
+            return e
+          },
+        }).pipe(Effect.orElseSucceed(() => undefined))
+      })
+
+      const getPrompt = Effect.fn("MCP.getPrompt")(function* (
+        clientName: string,
+        name: string,
+        args?: Record<string, string>,
+      ) {
+        return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", {
           promptName: name,
-          error: e.message,
         })
-        return undefined
       })
 
-    return result
-  }
-
-  export async function readResource(clientName: string, resourceUri: string) {
-    const clientsSnapshot = await clients()
-    const client = clientsSnapshot[clientName]
-
-    if (!client) {
-      log.warn("client not found for prompt", {
-        clientName: clientName,
+      const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) {
+        return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", {
+          resourceUri,
+        })
       })
-      return undefined
-    }
 
-    const result = await client
-      .readResource({
-        uri: resourceUri,
+      const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
+        const cfg = yield* Effect.promise(() => Config.get())
+        const mcpConfig = cfg.mcp?.[mcpName]
+        if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
+        return mcpConfig
       })
-      .catch((e) => {
-        log.error("failed to get prompt from MCP server", {
-          clientName: clientName,
-          resourceUri: resourceUri,
-          error: e.message,
+
+      const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
+        if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
+        if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
+
+        yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
+
+        const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
+          .map((b) => b.toString(16).padStart(2, "0"))
+          .join("")
+        yield* auth.updateOAuthState(mcpName, oauthState)
+        const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
+        let capturedUrl: URL | undefined
+        const authProvider = new McpOAuthProvider(
+          mcpName,
+          mcpConfig.url,
+          {
+            clientId: oauthConfig?.clientId,
+            clientSecret: oauthConfig?.clientSecret,
+            scope: oauthConfig?.scope,
+          },
+          {
+            onRedirect: async (url) => {
+              capturedUrl = url
+            },
+          },
+        )
+
+        const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
+
+        return yield* Effect.promise(async () => {
+          try {
+            const client = new Client({ name: "opencode", version: Installation.VERSION })
+            await client.connect(transport)
+            return { authorizationUrl: "", oauthState }
+          } catch (error) {
+            if (error instanceof UnauthorizedError && capturedUrl) {
+              pendingOAuthTransports.set(mcpName, transport)
+              return { authorizationUrl: capturedUrl.toString(), oauthState }
+            }
+            throw error
+          }
         })
-        return undefined
       })
 
-    return result
-  }
+      const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
+        const { authorizationUrl, oauthState } = yield* startAuth(mcpName)
+        if (!authorizationUrl) return { status: "connected" } as Status
+
+        log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
+
+        const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)
+
+        yield* Effect.tryPromise(() => open(authorizationUrl)).pipe(
+          Effect.flatMap((subprocess) =>
+            Effect.callback<void, Error>((resume) => {
+              const timer = setTimeout(() => resume(Effect.void), 500)
+              subprocess.on("error", (err) => {
+                clearTimeout(timer)
+                resume(Effect.fail(err))
+              })
+              subprocess.on("exit", (code) => {
+                if (code !== null && code !== 0) {
+                  clearTimeout(timer)
+                  resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
+                }
+              })
+            }),
+          ),
+          Effect.catch(() => {
+            log.warn("failed to open browser, user must open URL manually", { mcpName })
+            return Effect.promise(() => Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }))
+          }),
+        )
 
-  /**
-   * Start OAuth authentication flow for an MCP server.
-   * Returns the authorization URL that should be opened in a browser.
-   */
-  export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
+        const code = yield* Effect.promise(() => callbackPromise)
 
-    if (!mcpConfig) {
-      throw new Error(`MCP server not found: ${mcpName}`)
-    }
+        const storedState = yield* auth.getOAuthState(mcpName)
+        if (storedState !== oauthState) {
+          yield* auth.clearOAuthState(mcpName)
+          throw new Error("OAuth state mismatch - potential CSRF attack")
+        }
+        yield* auth.clearOAuthState(mcpName)
+        return yield* finishAuth(mcpName, code)
+      })
 
-    if (!isMcpConfigured(mcpConfig)) {
-      throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-    }
+      const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
+        const transport = pendingOAuthTransports.get(mcpName)
+        if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
 
-    if (mcpConfig.type !== "remote") {
-      throw new Error(`MCP server ${mcpName} is not a remote server`)
-    }
+        const result = yield* Effect.tryPromise({
+          try: async () => {
+            await transport.finishAuth(authorizationCode)
+            return true
+          },
+          catch: (error) => {
+            log.error("failed to finish oauth", { mcpName, error })
+            return error
+          },
+        }).pipe(Effect.option)
 
-    if (mcpConfig.oauth === false) {
-      throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
-    }
+        if (Option.isNone(result)) {
+          return { status: "failed", error: "OAuth completion failed" } as Status
+        }
 
-    // Start the callback server
-    await McpOAuthCallback.ensureRunning()
-
-    // Generate and store a cryptographically secure state parameter BEFORE creating the provider
-    // The SDK will call provider.state() to read this value
-    const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
-      .map((b) => b.toString(16).padStart(2, "0"))
-      .join("")
-    await McpAuth.updateOAuthState(mcpName, oauthState)
-
-    // Create a new auth provider for this flow
-    // OAuth config is optional - if not provided, we'll use auto-discovery
-    const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
-    let capturedUrl: URL | undefined
-    const authProvider = new McpOAuthProvider(
-      mcpName,
-      mcpConfig.url,
-      {
-        clientId: oauthConfig?.clientId,
-        clientSecret: oauthConfig?.clientSecret,
-        scope: oauthConfig?.scope,
-      },
-      {
-        onRedirect: async (url) => {
-          capturedUrl = url
-        },
-      },
-    )
+        yield* auth.clearCodeVerifier(mcpName)
+        pendingOAuthTransports.delete(mcpName)
 
-    // Create transport with auth provider
-    const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
-      authProvider,
-    })
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
 
-    // Try to connect - this will trigger the OAuth flow
-    try {
-      const client = new Client({
-        name: "opencode",
-        version: Installation.VERSION,
+        return yield* createAndStore(mcpName, mcpConfig)
       })
-      await client.connect(transport)
-      // If we get here, we're already authenticated
-      return { authorizationUrl: "" }
-    } catch (error) {
-      if (error instanceof UnauthorizedError && capturedUrl) {
-        // Store transport for finishAuth
-        pendingOAuthTransports.set(mcpName, transport)
-        return { authorizationUrl: capturedUrl.toString() }
-      }
-      throw error
-    }
-  }
 
-  /**
-   * Complete OAuth authentication after user authorizes in browser.
-   * Opens the browser and waits for callback.
-   */
-  export async function authenticate(mcpName: string): Promise<Status> {
-    const { authorizationUrl } = await startAuth(mcpName)
-
-    if (!authorizationUrl) {
-      // Already authenticated
-      const s = await state()
-      return s.status[mcpName] ?? { status: "connected" }
-    }
+      const removeAuth = Effect.fn("MCP.removeAuth")(function* (mcpName: string) {
+        yield* auth.remove(mcpName)
+        McpOAuthCallback.cancelPending(mcpName)
+        pendingOAuthTransports.delete(mcpName)
+        log.info("removed oauth credentials", { mcpName })
+      })
 
-    // Get the state that was already generated and stored in startAuth()
-    const oauthState = await McpAuth.getOAuthState(mcpName)
-    if (!oauthState) {
-      throw new Error("OAuth state not found - this should not happen")
-    }
+      const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {
+        const mcpConfig = yield* getMcpConfig(mcpName)
+        if (!mcpConfig) return false
+        return mcpConfig.type === "remote" && mcpConfig.oauth !== false
+      })
 
-    // The SDK has already added the state parameter to the authorization URL
-    // We just need to open the browser
-    log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
-
-    // Register the callback BEFORE opening the browser to avoid race condition
-    // when the IdP has an active SSO session and redirects immediately
-    const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
-
-    try {
-      const subprocess = await open(authorizationUrl)
-      // The open package spawns a detached process and returns immediately.
-      // We need to listen for errors which fire asynchronously:
-      // - "error" event: command not found (ENOENT)
-      // - "exit" with non-zero code: command exists but failed (e.g., no display)
-      await new Promise<void>((resolve, reject) => {
-        // Give the process a moment to fail if it's going to
-        const timeout = setTimeout(() => resolve(), 500)
-        subprocess.on("error", (error) => {
-          clearTimeout(timeout)
-          reject(error)
-        })
-        subprocess.on("exit", (code) => {
-          if (code !== null && code !== 0) {
-            clearTimeout(timeout)
-            reject(new Error(`Browser open failed with exit code ${code}`))
-          }
-        })
+      const hasStoredTokens = Effect.fn("MCP.hasStoredTokens")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        return !!entry?.tokens
       })
-    } catch (error) {
-      // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
-      // Emit event so CLI can display the URL for manual opening
-      log.warn("failed to open browser, user must open URL manually", { mcpName, error })
-      Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
-    }
 
-    // Wait for callback using the already-registered promise
-    const code = await callbackPromise
+      const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) {
+        const entry = yield* auth.get(mcpName)
+        if (!entry?.tokens) return "not_authenticated" as AuthStatus
+        const expired = yield* auth.isTokenExpired(mcpName)
+        return (expired ? "expired" : "authenticated") as AuthStatus
+      })
 
-    // Validate and clear the state
-    const storedState = await McpAuth.getOAuthState(mcpName)
-    if (storedState !== oauthState) {
-      await McpAuth.clearOAuthState(mcpName)
-      throw new Error("OAuth state mismatch - potential CSRF attack")
-    }
+      return Service.of({
+        status,
+        clients,
+        tools,
+        prompts,
+        resources,
+        add,
+        connect,
+        disconnect,
+        getPrompt,
+        readResource,
+        startAuth,
+        authenticate,
+        finishAuth,
+        removeAuth,
+        supportsOAuth,
+        hasStoredTokens,
+        getAuthStatus,
+      })
+    }),
+  )
 
-    await McpAuth.clearOAuthState(mcpName)
+  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
 
-    // Finish auth
-    return finishAuth(mcpName, code)
-  }
+  // --- Per-service runtime ---
 
-  /**
-   * Complete OAuth authentication with the authorization code.
-   */
-  export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
-    const transport = pendingOAuthTransports.get(mcpName)
+  const defaultLayer = layer.pipe(
+    Layer.provide(McpAuth.layer),
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
 
-    if (!transport) {
-      throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
-    }
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
-    try {
-      // Call finishAuth on the transport
-      await transport.finishAuth(authorizationCode)
+  // --- Async facade functions ---
 
-      // Clear the code verifier after successful auth
-      await McpAuth.clearCodeVerifier(mcpName)
+  export const status = async () => runPromise((svc) => svc.status())
 
-      // Now try to reconnect
-      const cfg = await Config.get()
-      const mcpConfig = cfg.mcp?.[mcpName]
+  export const clients = async () => runPromise((svc) => svc.clients())
 
-      if (!mcpConfig) {
-        throw new Error(`MCP server not found: ${mcpName}`)
-      }
+  export const tools = async () => runPromise((svc) => svc.tools())
 
-      if (!isMcpConfigured(mcpConfig)) {
-        throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
-      }
+  export const prompts = async () => runPromise((svc) => svc.prompts())
 
-      // Re-add the MCP server to establish connection
-      pendingOAuthTransports.delete(mcpName)
-      const result = await add(mcpName, mcpConfig)
+  export const resources = async () => runPromise((svc) => svc.resources())
 
-      const statusRecord = result.status as Record<string, Status>
-      return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
-    } catch (error) {
-      log.error("failed to finish oauth", { mcpName, error })
-      return {
-        status: "failed",
-        error: error instanceof Error ? error.message : String(error),
-      }
-    }
-  }
+  export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
 
-  /**
-   * Remove OAuth credentials for an MCP server.
-   */
-  export async function removeAuth(mcpName: string): Promise<void> {
-    await McpAuth.remove(mcpName)
-    McpOAuthCallback.cancelPending(mcpName)
-    pendingOAuthTransports.delete(mcpName)
-    await McpAuth.clearOAuthState(mcpName)
-    log.info("removed oauth credentials", { mcpName })
-  }
+  export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
 
-  /**
-   * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
-   */
-  export async function supportsOAuth(mcpName: string): Promise<boolean> {
-    const cfg = await Config.get()
-    const mcpConfig = cfg.mcp?.[mcpName]
-    if (!mcpConfig) return false
-    if (!isMcpConfigured(mcpConfig)) return false
-    return mcpConfig.type === "remote" && mcpConfig.oauth !== false
-  }
+  export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
 
-  /**
-   * Check if an MCP server has stored OAuth tokens.
-   */
-  export async function hasStoredTokens(mcpName: string): Promise<boolean> {
-    const entry = await McpAuth.get(mcpName)
-    return !!entry?.tokens
-  }
+  export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
+    runPromise((svc) => svc.getPrompt(clientName, name, args))
 
-  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
+  export const readResource = async (clientName: string, resourceUri: string) =>
+    runPromise((svc) => svc.readResource(clientName, resourceUri))
 
-  /**
-   * Get the authentication status for an MCP server.
-   */
-  export async function getAuthStatus(mcpName: string): Promise<AuthStatus> {
-    const hasTokens = await hasStoredTokens(mcpName)
-    if (!hasTokens) return "not_authenticated"
-    const expired = await McpAuth.isTokenExpired(mcpName)
-    return expired ? "expired" : "authenticated"
-  }
+  export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
+
+  export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
+
+  export const finishAuth = async (mcpName: string, authorizationCode: string) =>
+    runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
+
+  export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
+
+  export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
+
+  export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
+
+  export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
 }

+ 26 - 3
packages/opencode/src/mcp/oauth-callback.ts

@@ -54,6 +54,9 @@ interface PendingAuth {
 export namespace McpOAuthCallback {
   let server: ReturnType<typeof Bun.serve> | undefined
   const pendingAuths = new Map<string, PendingAuth>()
+  // Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
+  // find the right entry in pendingAuths (which is keyed by oauthState).
+  const mcpNameToState = new Map<string, string>()
 
   const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
 
@@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
             const pending = pendingAuths.get(state)!
             clearTimeout(pending.timeout)
             pendingAuths.delete(state)
+            for (const [name, s] of mcpNameToState) {
+              if (s === state) {
+                mcpNameToState.delete(name)
+                break
+              }
+            }
             pending.reject(new Error(errorMsg))
           }
           return new Response(HTML_ERROR(errorMsg), {
@@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
 
         clearTimeout(pending.timeout)
         pendingAuths.delete(state)
+        // Clean up reverse index
+        for (const [name, s] of mcpNameToState) {
+          if (s === state) {
+            mcpNameToState.delete(name)
+            break
+          }
+        }
         pending.resolve(code)
 
         return new Response(HTML_SUCCESS, {
@@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
     log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
   }
 
-  export function waitForCallback(oauthState: string): Promise<string> {
+  export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
+    if (mcpName) mcpNameToState.set(mcpName, oauthState)
     return new Promise((resolve, reject) => {
       const timeout = setTimeout(() => {
         if (pendingAuths.has(oauthState)) {
           pendingAuths.delete(oauthState)
+          if (mcpName) mcpNameToState.delete(mcpName)
           reject(new Error("OAuth callback timeout - authorization took too long"))
         }
       }, CALLBACK_TIMEOUT_MS)
@@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
   }
 
   export function cancelPending(mcpName: string): void {
-    const pending = pendingAuths.get(mcpName)
+    // Look up the oauthState for this mcpName via the reverse index
+    const oauthState = mcpNameToState.get(mcpName)
+    const key = oauthState ?? mcpName
+    const pending = pendingAuths.get(key)
     if (pending) {
       clearTimeout(pending.timeout)
-      pendingAuths.delete(mcpName)
+      pendingAuths.delete(key)
+      mcpNameToState.delete(mcpName)
       pending.reject(new Error("Authorization cancelled"))
     }
   }
@@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
       pending.reject(new Error("OAuth callback server stopped"))
     }
     pendingAuths.clear()
+    mcpNameToState.clear()
   }
 
   export function isRunning(): boolean {

+ 2 - 2
packages/opencode/src/permission/index.ts

@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Config } from "@/config/config"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { ProjectID } from "@/project/schema"
 import { Instance } from "@/project/instance"
 import { MessageID, SessionID } from "@/session/schema"
@@ -306,7 +306,7 @@ export namespace Permission {
     return result
   }
 
-  export const runPromise = makeRunPromise(Service, layer)
+  export const { runPromise } = makeRuntime(Service, layer)
 
   export async function ask(input: z.infer<typeof AskInput>) {
     return runPromise((s) => s.ask(input))

+ 14 - 10
packages/opencode/src/plugin/index.ts

@@ -3,7 +3,6 @@ import { Config } from "../config/config"
 import { Bus } from "../bus"
 import { Log } from "../util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
-import { Server } from "../server/server"
 import { BunProc } from "../bun"
 import { Flag } from "../flag/flag"
 import { CodexAuthPlugin } from "./codex"
@@ -12,9 +11,9 @@ import { NamedError } from "@opencode-ai/util/error"
 import { CopilotAuthPlugin } from "./copilot"
 import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
 import { PoeAuthPlugin } from "opencode-poe-auth"
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
@@ -53,11 +52,15 @@ export namespace Plugin {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const bus = yield* Bus.Service
+
       const cache = yield* InstanceState.make<State>(
         Effect.fn("Plugin.state")(function* (ctx) {
           const hooks: Hooks[] = []
 
           yield* Effect.promise(async () => {
+            const { Server } = await import("../server/server")
+
             const client = createOpencodeClient({
               baseUrl: "http://localhost:4096",
               directory: ctx.directory,
@@ -145,16 +148,16 @@ export namespace Plugin {
             }
           })
 
-          // Subscribe to bus events, clean up when scope is closed
-          yield* Effect.acquireRelease(
-            Effect.sync(() =>
-              Bus.subscribeAll(async (input) => {
+          // Subscribe to bus events, fiber interrupted when scope closes
+          yield* bus.subscribeAll().pipe(
+            Stream.runForEach((input) =>
+              Effect.sync(() => {
                 for (const hook of hooks) {
-                  hook["event"]?.({ event: input })
+                  hook["event"]?.({ event: input as any })
                 }
               }),
             ),
-            (unsub) => Effect.sync(unsub),
+            Effect.forkScoped,
           )
 
           return { hooks }
@@ -191,7 +194,8 @@ export namespace Plugin {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function trigger<
     Name extends TriggerName,

+ 2 - 2
packages/opencode/src/project/project.ts

@@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
 import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
@@ -462,7 +462,7 @@ export namespace Project {
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),
   )
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   // ---------------------------------------------------------------------------
   // Promise-based API (delegates to Effect service via runPromise)

+ 161 - 35
packages/opencode/src/project/vcs.ts

@@ -1,17 +1,111 @@
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, ServiceMap, Stream } from "effect"
+import path from "path"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
 import { FileWatcher } from "@/file/watcher"
+import { Git } from "@/git"
+import { Snapshot } from "@/snapshot"
 import { Log } from "@/util/log"
-import { git } from "@/util/git"
 import { Instance } from "./instance"
 import z from "zod"
 
 export namespace Vcs {
   const log = Log.create({ service: "vcs" })
 
+  const count = (text: string) => {
+    if (!text) return 0
+    if (!text.endsWith("\n")) return text.split("\n").length
+    return text.slice(0, -1).split("\n").length
+  }
+
+  const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
+    const full = path.join(cwd, file)
+    if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
+    const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+    if (Buffer.from(buf).includes(0)) return ""
+    return Buffer.from(buf).toString("utf8")
+  })
+
+  const nums = (list: Git.Stat[]) =>
+    new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
+
+  const merge = (...lists: Git.Item[][]) => {
+    const out = new Map<string, Git.Item>()
+    lists.flat().forEach((item) => {
+      if (!out.has(item.file)) out.set(item.file, item)
+    })
+    return [...out.values()]
+  }
+
+  const files = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string | undefined,
+    list: Git.Item[],
+    map: Map<string, { additions: number; deletions: number }>,
+  ) {
+    const base = ref ? yield* git.prefix(cwd) : ""
+    const next = yield* Effect.forEach(
+      list,
+      (item) =>
+        Effect.gen(function* () {
+          const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
+          const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
+          const stat = map.get(item.file)
+          return {
+            file: item.file,
+            before,
+            after,
+            additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
+            deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
+            status: item.status,
+          } satisfies Snapshot.FileDiff
+        }),
+      { concurrency: 8 },
+    )
+    return next.toSorted((a, b) => a.file.localeCompare(b.file))
+  })
+
+  const track = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string | undefined,
+  ) {
+    if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
+    const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
+    return yield* files(fs, git, cwd, ref, list, nums(stats))
+  })
+
+  const compare = Effect.fnUntraced(function* (
+    fs: AppFileSystem.Interface,
+    git: Git.Interface,
+    cwd: string,
+    ref: string,
+  ) {
+    const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
+      concurrency: 3,
+    })
+    return yield* files(
+      fs,
+      git,
+      cwd,
+      ref,
+      merge(
+        list,
+        extra.filter((item) => item.code === "??"),
+      ),
+      nums(stats),
+    )
+  })
+
+  export const Mode = z.enum(["git", "branch"])
+  export type Mode = z.infer<typeof Mode>
+
   export const Event = {
     BranchUpdated: BusEvent.define(
       "vcs.branch.updated",
@@ -23,7 +117,8 @@ export namespace Vcs {
 
   export const Info = z
     .object({
-      branch: z.string(),
+      branch: z.string().optional(),
+      default_branch: z.string().optional(),
     })
     .meta({
       ref: "VcsInfo",
@@ -33,54 +128,50 @@ export namespace Vcs {
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly branch: () => Effect.Effect<string | undefined>
+    readonly defaultBranch: () => Effect.Effect<string | undefined>
+    readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
   }
 
   interface State {
     current: string | undefined
+    root: Git.Base | undefined
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
 
-  export const layer = Layer.effect(
+  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
+      const bus = yield* Bus.Service
       const state = yield* InstanceState.make<State>(
         Effect.fn("Vcs.state")((ctx) =>
           Effect.gen(function* () {
             if (ctx.project.vcs !== "git") {
-              return { current: undefined }
+              return { current: undefined, root: undefined }
             }
 
-            const getCurrentBranch = async () => {
-              const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
-                cwd: ctx.worktree,
-              })
-              if (result.exitCode !== 0) return undefined
-              const text = result.text().trim()
-              return text || undefined
-            }
+            const get = () => Effect.runPromise(git.branch(ctx.directory))
+            const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
+              concurrency: 2,
+            })
+            const value = { current, root }
+            log.info("initialized", { branch: value.current, default_branch: value.root?.name })
 
-            const value = {
-              current: yield* Effect.promise(() => getCurrentBranch()),
-            }
-            log.info("initialized", { branch: value.current })
-
-            yield* Effect.acquireRelease(
-              Effect.sync(() =>
-                Bus.subscribe(
-                  FileWatcher.Event.Updated,
-                  Instance.bind(async (evt) => {
-                    if (!evt.properties.file.endsWith("HEAD")) return
-                    const next = await getCurrentBranch()
-                    if (next !== value.current) {
-                      log.info("branch changed", { from: value.current, to: next })
-                      value.current = next
-                      Bus.publish(Event.BranchUpdated, { branch: next })
-                    }
-                  }),
-                ),
+            yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
+              Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
+              Stream.runForEach((_evt) =>
+                Effect.gen(function* () {
+                  const next = yield* Effect.promise(() => get())
+                  if (next !== value.current) {
+                    log.info("branch changed", { from: value.current, to: next })
+                    value.current = next
+                    yield* bus.publish(Event.BranchUpdated, { branch: next })
+                  }
+                }),
               ),
-              (unsubscribe) => Effect.sync(unsubscribe),
+              Effect.forkScoped,
             )
 
             return value
@@ -95,11 +186,38 @@ export namespace Vcs {
         branch: Effect.fn("Vcs.branch")(function* () {
           return yield* InstanceState.use(state, (x) => x.current)
         }),
+        defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
+          return yield* InstanceState.use(state, (x) => x.root?.name)
+        }),
+        diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
+          const value = yield* InstanceState.get(state)
+          if (Instance.project.vcs !== "git") return []
+          if (mode === "git") {
+            return yield* track(
+              fs,
+              git,
+              Instance.directory,
+              (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
+            )
+          }
+
+          if (!value.root) return []
+          if (value.current && value.current === value.root.name) return []
+          const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
+          if (!ref) return []
+          return yield* compare(fs, git, Instance.directory, ref)
+        }),
       })
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Git.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Bus.layer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export function init() {
     return runPromise((svc) => svc.init())
@@ -108,4 +226,12 @@ export namespace Vcs {
   export function branch() {
     return runPromise((svc) => svc.branch())
   }
+
+  export function defaultBranch() {
+    return runPromise((svc) => svc.defaultBranch())
+  }
+
+  export function diff(mode: Mode) {
+    return runPromise((svc) => svc.diff(mode))
+  }
 }

+ 2 - 2
packages/opencode/src/provider/auth.ts

@@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/util/error"
 import { Auth } from "@/auth"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Plugin } from "../plugin"
 import { ProviderID } from "./schema"
 import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
@@ -231,7 +231,7 @@ export namespace ProviderAuth {
 
   export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
 
-  const runPromise = makeRunPromise(Service, defaultLayer)
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function methods() {
     return runPromise((svc) => svc.methods())

+ 2 - 2
packages/opencode/src/pty/index.ts

@@ -1,7 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { Instance } from "@/project/instance"
 import { type IPty } from "bun-pty"
 import z from "zod"
@@ -361,7 +361,7 @@ export namespace Pty {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function list() {
     return runPromise((svc) => svc.list())

+ 2 - 2
packages/opencode/src/question/index.ts

@@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRunPromise } from "@/effect/run-service"
+import { makeRuntime } from "@/effect/run-service"
 import { SessionID, MessageID } from "@/session/schema"
 import { Log } from "@/util/log"
 import z from "zod"
@@ -197,7 +197,7 @@ export namespace Question {
     }),
   )
 
-  const runPromise = makeRunPromise(Service, layer)
+  const { runPromise } = makeRuntime(Service, layer)
 
   export async function ask(input: {
     sessionID: SessionID

+ 28 - 0
packages/opencode/src/server/projectors.ts

@@ -0,0 +1,28 @@
+import z from "zod"
+import sessionProjectors from "../session/projectors"
+import { SyncEvent } from "@/sync"
+import { Session } from "@/session"
+import { SessionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
+
+export function initProjectors() {
+  SyncEvent.init({
+    projectors: sessionProjectors,
+    convertEvent: (type, data) => {
+      if (type === "session.updated") {
+        const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
+        const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+
+        if (!row) return data
+
+        return {
+          sessionID: id,
+          info: Session.fromRow(row),
+        }
+      }
+      return data
+    },
+  })
+}
+
+initProjectors()

+ 7 - 8
packages/opencode/src/server/routes/event.ts

@@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
 import { lazy } from "../../util/lazy"
 import { AsyncQueue } from "../../util/queue"
-import { Instance } from "@/project/instance"
 
 const log = Log.create({ service: "server" })
 
@@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
           )
         }, 10_000)
 
-        const unsub = Bus.subscribeAll((event) => {
-          q.push(JSON.stringify(event))
-          if (event.type === Bus.InstanceDisposed.type) {
-            stop()
-          }
-        })
-
         const stop = () => {
           if (done) return
           done = true
@@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
           log.info("event disconnected")
         }
 
+        const unsub = Bus.subscribeAll((event) => {
+          q.push(JSON.stringify(event))
+          if (event.type === Bus.InstanceDisposed.type) {
+            stop()
+          }
+        })
+
         stream.onAbort(stop)
 
         try {

+ 97 - 44
packages/opencode/src/server/routes/global.ts

@@ -1,9 +1,9 @@
-import { Hono } from "hono"
-import { describeRoute, validator, resolver } from "hono-openapi"
+import { Hono, type Context } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
 import { streamSSE } from "hono/streaming"
 import z from "zod"
-import { Bus } from "../../bus"
 import { BusEvent } from "@/bus/bus-event"
+import { SyncEvent } from "@/sync"
 import { GlobalBus } from "@/bus/global"
 import { AsyncQueue } from "@/util/queue"
 import { Instance } from "../../project/instance"
@@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
 
 export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
 
+async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
+  return streamSSE(c, async (stream) => {
+    const q = new AsyncQueue<string | null>()
+    let done = false
+
+    q.push(
+      JSON.stringify({
+        payload: {
+          type: "server.connected",
+          properties: {},
+        },
+      }),
+    )
+
+    // Send heartbeat every 10s to prevent stalled proxy streams.
+    const heartbeat = setInterval(() => {
+      q.push(
+        JSON.stringify({
+          payload: {
+            type: "server.heartbeat",
+            properties: {},
+          },
+        }),
+      )
+    }, 10_000)
+
+    const stop = () => {
+      if (done) return
+      done = true
+      clearInterval(heartbeat)
+      unsub()
+      q.push(null)
+      log.info("global event disconnected")
+    }
+
+    const unsub = subscribe(q)
+
+    stream.onAbort(stop)
+
+    try {
+      for await (const data of q) {
+        if (data === null) return
+        await stream.writeSSE({ data })
+      }
+    } finally {
+      stop()
+    }
+  })
+}
+
 export const GlobalRoutes = lazy(() =>
   new Hono()
     .get(
@@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
         log.info("global event connected")
         c.header("X-Accel-Buffering", "no")
         c.header("X-Content-Type-Options", "nosniff")
-        return streamSSE(c, async (stream) => {
-          const q = new AsyncQueue<string | null>()
-          let done = false
 
-          q.push(
-            JSON.stringify({
-              payload: {
-                type: "server.connected",
-                properties: {},
+        return streamEvents(c, (q) => {
+          async function handler(event: any) {
+            q.push(JSON.stringify(event))
+          }
+          GlobalBus.on("event", handler)
+          return () => GlobalBus.off("event", handler)
+        })
+      },
+    )
+    .get(
+      "/sync-event",
+      describeRoute({
+        summary: "Subscribe to global sync events",
+        description: "Get global sync events",
+        operationId: "global.sync-event.subscribe",
+        responses: {
+          200: {
+            description: "Event stream",
+            content: {
+              "text/event-stream": {
+                schema: resolver(
+                  z
+                    .object({
+                      payload: SyncEvent.payloads(),
+                    })
+                    .meta({
+                      ref: "SyncEvent",
+                    }),
+                ),
               },
-            }),
-          )
-
-          // Send heartbeat every 10s to prevent stalled proxy streams.
-          const heartbeat = setInterval(() => {
+            },
+          },
+        },
+      }),
+      async (c) => {
+        log.info("global sync event connected")
+        c.header("X-Accel-Buffering", "no")
+        c.header("X-Content-Type-Options", "nosniff")
+        return streamEvents(c, (q) => {
+          return SyncEvent.subscribeAll(({ def, event }) => {
+            // TODO: don't pass def, just pass the type (and it should
+            // be versioned)
             q.push(
               JSON.stringify({
                 payload: {
-                  type: "server.heartbeat",
-                  properties: {},
+                  ...event,
+                  type: SyncEvent.versionedType(def.type, def.version),
                 },
               }),
             )
-          }, 10_000)
-
-          async function handler(event: any) {
-            q.push(JSON.stringify(event))
-          }
-          GlobalBus.on("event", handler)
-
-          const stop = () => {
-            if (done) return
-            done = true
-            clearInterval(heartbeat)
-            GlobalBus.off("event", handler)
-            q.push(null)
-            log.info("event disconnected")
-          }
-
-          stream.onAbort(stop)
-
-          try {
-            for await (const data of q) {
-              if (data === null) return
-              await stream.writeSSE({ data })
-            }
-          } finally {
-            stop()
-          }
+          })
         })
       },
     )

+ 3 - 3
packages/opencode/src/server/routes/session.ts

@@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
         const sessionID = c.req.valid("param").sessionID
         const updates = c.req.valid("json")
 
-        let session = await Session.get(sessionID)
         if (updates.title !== undefined) {
-          session = await Session.setTitle({ sessionID, title: updates.title })
+          await Session.setTitle({ sessionID, title: updates.title })
         }
         if (updates.time?.archived !== undefined) {
-          session = await Session.setArchived({ sessionID, time: updates.time.archived })
+          await Session.setArchived({ sessionID, time: updates.time.archived })
         }
 
+        const session = await Session.get(sessionID)
         return c.json(session)
       },
     )

+ 44 - 5
packages/opencode/src/server/server.ts

@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto"
 import { Log } from "../util/log"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { Hono } from "hono"
@@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
 import { HTTPException } from "hono/http-exception"
 import { errors } from "./error"
 import { Filesystem } from "@/util/filesystem"
+import { Snapshot } from "@/snapshot"
 import { QuestionRoutes } from "./routes/question"
 import { PermissionRoutes } from "./routes/permission"
 import { GlobalRoutes } from "./routes/global"
 import { MDNS } from "./mdns"
 import { lazy } from "@/util/lazy"
+import { initProjectors } from "./projectors"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
 
+const csp = (hash = "") =>
+  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
+initProjectors()
+
 export namespace Server {
   const log = Log.create({ service: "server" })
 
@@ -330,12 +338,40 @@ export namespace Server {
           },
         }),
         async (c) => {
-          const branch = await Vcs.branch()
+          const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
           return c.json({
             branch,
+            default_branch,
           })
         },
       )
+      .get(
+        "/vcs/diff",
+        describeRoute({
+          summary: "Get VCS diff",
+          description: "Retrieve the current git diff for the working tree or against the default branch.",
+          operationId: "vcs.diff",
+          responses: {
+            200: {
+              description: "VCS diff",
+              content: {
+                "application/json": {
+                  schema: resolver(Snapshot.FileDiff.array()),
+                },
+              },
+            },
+          },
+        }),
+        validator(
+          "query",
+          z.object({
+            mode: Vcs.Mode,
+          }),
+        ),
+        async (c) => {
+          return c.json(await Vcs.diff(c.req.valid("query").mode))
+        },
+      )
       .get(
         "/command",
         describeRoute({
@@ -506,10 +542,13 @@ export namespace Server {
             host: "app.opencode.ai",
           },
         })
-        response.headers.set(
-          "Content-Security-Policy",
-          "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
-        )
+        const match = response.headers.get("content-type")?.includes("text/html")
+          ? (await response.clone().text()).match(
+              /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+            )
+          : undefined
+        const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
+        response.headers.set("Content-Security-Policy", csp(hash))
         return response
       })
   }

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

@@ -13,6 +13,7 @@ import { fn } from "@/util/fn"
 import { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config/config"
+import { NotFoundError } from "@/storage/db"
 import { ProviderTransform } from "@/provider/transform"
 import { ModelID, ProviderID } from "@/provider/schema"
 
@@ -60,7 +61,11 @@ export namespace SessionCompaction {
     const config = await Config.get()
     if (config.compaction?.prune === false) return
     log.info("pruning")
-    const msgs = await Session.messages({ sessionID: input.sessionID })
+    const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
+      if (NotFoundError.isInstance(err)) return undefined
+      throw err
+    })
+    if (!msgs) return
     let total = 0
     let pruned = 0
     const toPrune = []

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.