Explorar el Código

Merge branch 'dev' into kit/windows-session-restore

Kit Langton hace 4 meses
padre
commit
0aa7525da8
Se han modificado 100 ficheros con 4091 adiciones y 1271 borrados
  1. 24 0
      .github/workflows/close-issues.yml
  2. 0 33
      .github/workflows/stale-issues.yml
  3. 7 3
      bun.lock
  4. 0 1
      github/index.ts
  5. 4 4
      nix/hashes.json
  6. 2 1
      packages/app/playwright.config.ts
  7. 4 4
      packages/app/src/app.tsx
  8. 38 12
      packages/app/src/components/dialog-connect-provider.tsx
  9. 1 0
      packages/app/src/components/prompt-input.tsx
  10. 38 12
      packages/app/src/components/settings-general.tsx
  11. 19 4
      packages/app/src/components/status-popover.tsx
  12. 4 1
      packages/app/src/components/terminal.tsx
  13. 1 1
      packages/app/src/components/titlebar.tsx
  14. 19 0
      packages/app/src/context/command-keybind.test.ts
  15. 28 30
      packages/app/src/context/global-sync.tsx
  16. 207 132
      packages/app/src/context/global-sync/bootstrap.ts
  17. 6 4
      packages/app/src/context/global-sync/event-reducer.test.ts
  18. 2 2
      packages/app/src/context/global-sync/event-reducer.ts
  19. 35 0
      packages/app/src/context/global-sync/utils.test.ts
  20. 15 1
      packages/app/src/context/global-sync/utils.ts
  21. 66 78
      packages/app/src/context/language.tsx
  22. 3 3
      packages/app/src/context/notification.tsx
  23. 12 1
      packages/app/src/context/settings.tsx
  24. 4 3
      packages/app/src/context/sync.tsx
  25. 12 39
      packages/app/src/context/terminal-title.ts
  26. 1 1
      packages/app/src/hooks/use-providers.ts
  27. 0 2
      packages/app/src/i18n/ar.ts
  28. 0 2
      packages/app/src/i18n/br.ts
  29. 0 2
      packages/app/src/i18n/bs.ts
  30. 0 2
      packages/app/src/i18n/da.ts
  31. 0 2
      packages/app/src/i18n/de.ts
  32. 2 2
      packages/app/src/i18n/en.ts
  33. 0 2
      packages/app/src/i18n/es.ts
  34. 0 2
      packages/app/src/i18n/fr.ts
  35. 0 2
      packages/app/src/i18n/ja.ts
  36. 0 2
      packages/app/src/i18n/ko.ts
  37. 0 2
      packages/app/src/i18n/no.ts
  38. 0 2
      packages/app/src/i18n/pl.ts
  39. 0 2
      packages/app/src/i18n/ru.ts
  40. 0 2
      packages/app/src/i18n/th.ts
  41. 0 2
      packages/app/src/i18n/tr.ts
  42. 0 2
      packages/app/src/i18n/zh.ts
  43. 0 2
      packages/app/src/i18n/zht.ts
  44. 1 0
      packages/app/src/index.ts
  45. 29 41
      packages/app/src/pages/directory-layout.tsx
  46. 8 0
      packages/app/src/pages/home.tsx
  47. 43 21
      packages/app/src/pages/layout.tsx
  48. 236 43
      packages/app/src/pages/session.tsx
  49. 2 1
      packages/app/src/pages/session/message-timeline.tsx
  50. 22 36
      packages/app/src/pages/session/session-side-panel.tsx
  51. 3 7
      packages/app/src/pages/session/use-session-commands.tsx
  52. 18 0
      packages/app/src/pages/session/use-session-hash-scroll.ts
  53. 23 1
      packages/app/src/utils/server-health.ts
  54. 81 96
      packages/app/src/utils/sound.ts
  55. 12 0
      packages/app/vite.js
  56. 1 1
      packages/desktop-electron/src/main/ipc.ts
  57. 1 1
      packages/desktop-electron/src/main/store.ts
  58. 17 3
      packages/desktop-electron/src/renderer/index.tsx
  59. 17 2
      packages/desktop/src/index.tsx
  60. 13 0
      packages/opencode/migration/20260323234822_events/migration.sql
  61. 1271 0
      packages/opencode/migration/20260323234822_events/snapshot.json
  62. 2 2
      packages/opencode/package.json
  63. 3 2
      packages/opencode/src/account/repo.ts
  64. 0 1
      packages/opencode/src/agent/agent.ts
  65. 0 3
      packages/opencode/src/bus/bus-event.ts
  66. 1 13
      packages/opencode/src/cli/cmd/agent.ts
  67. 6 7
      packages/opencode/src/cli/cmd/github.ts
  68. 4 4
      packages/opencode/src/cli/cmd/pr.ts
  69. 2 2
      packages/opencode/src/cli/cmd/tui/app.tsx
  70. 0 1
      packages/opencode/src/config/config.ts
  71. 7 7
      packages/opencode/src/file/index.ts
  72. 2 2
      packages/opencode/src/file/watcher.ts
  73. 308 0
      packages/opencode/src/git/index.ts
  74. 1 0
      packages/opencode/src/id/id.ts
  75. 6 0
      packages/opencode/src/lsp/index.ts
  76. 2 1
      packages/opencode/src/plugin/index.ts
  77. 147 24
      packages/opencode/src/project/vcs.ts
  78. 28 0
      packages/opencode/src/server/projectors.ts
  79. 7 8
      packages/opencode/src/server/routes/event.ts
  80. 97 44
      packages/opencode/src/server/routes/global.ts
  81. 3 3
      packages/opencode/src/server/routes/session.ts
  82. 44 5
      packages/opencode/src/server/server.ts
  83. 6 1
      packages/opencode/src/session/compaction.ts
  84. 98 199
      packages/opencode/src/session/index.ts
  85. 54 20
      packages/opencode/src/session/message-v2.ts
  86. 1 1
      packages/opencode/src/session/processor.ts
  87. 135 0
      packages/opencode/src/session/projectors.ts
  88. 6 6
      packages/opencode/src/session/revert.ts
  89. 9 10
      packages/opencode/src/share/share-next.ts
  90. 250 152
      packages/opencode/src/snapshot/index.ts
  91. 24 10
      packages/opencode/src/storage/db.ts
  92. 2 2
      packages/opencode/src/storage/storage.ts
  93. 179 0
      packages/opencode/src/sync/README.md
  94. 16 0
      packages/opencode/src/sync/event.sql.ts
  95. 263 0
      packages/opencode/src/sync/index.ts
  96. 14 0
      packages/opencode/src/sync/schema.ts
  97. 11 12
      packages/opencode/src/tool/task.ts
  98. 1 23
      packages/opencode/src/tool/todo.ts
  99. 0 14
      packages/opencode/src/tool/todoread.txt
  100. 0 35
      packages/opencode/src/util/git.ts

+ 24 - 0
.github/workflows/close-issues.yml

@@ -0,0 +1,24 @@
+name: close-issues
+
+on:
+  schedule:
+    - cron: "0 2 * * *" # Daily at 2:00 AM
+  workflow_dispatch:
+
+jobs:
+  close:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      issues: write
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: latest
+
+      - name: Close stale issues
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        run: bun script/github/close-issues.ts

+ 0 - 33
.github/workflows/stale-issues.yml

@@ -1,33 +0,0 @@
-name: stale-issues
-
-on:
-  schedule:
-    - cron: "30 1 * * *" # Daily at 1:30 AM
-  workflow_dispatch:
-
-env:
-  DAYS_BEFORE_STALE: 90
-  DAYS_BEFORE_CLOSE: 7
-
-jobs:
-  stale:
-    runs-on: ubuntu-latest
-    permissions:
-      issues: write
-    steps:
-      - uses: actions/stale@v10
-        with:
-          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
-          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
-          stale-issue-label: "stale"
-          close-issue-message: |
-            [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
-
-            Feel free to reopen if you still need this!
-          stale-issue-message: |
-            [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
-
-            It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
-          remove-stale-when-updated: true
-          exempt-issue-labels: "pinned,security,feature-request,on-hold"
-          start-date: "2025-12-27"

+ 7 - 3
bun.lock

@@ -329,7 +329,7 @@
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
         "@hono/standard-validator": "0.1.5",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
         "@hono/zod-validator": "catalog:",
-        "@modelcontextprotocol/sdk": "1.25.2",
+        "@modelcontextprotocol/sdk": "1.27.1",
         "@octokit/graphql": "9.0.2",
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
         "@openauthjs/openauth": "catalog:",
@@ -1325,7 +1325,7 @@
 
 
     "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
     "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
 
 
-    "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.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/sdk@1.27.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/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
     "@motionone/animation": ["@motionone/animation@10.18.0", "", { "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": ["express@4.22.1", "", { "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": ["express@4.22.1", "", { "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": ["expressive-code@0.41.7", "", { "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=="],
     "expressive-code": ["expressive-code@0.41.7", "", { "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=="],
 
 
@@ -5129,6 +5129,8 @@
 
 
     "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "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/express": ["express@5.2.1", "", { "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": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
+
     "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
     "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
 
 
     "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
     "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "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": ["express@5.2.1", "", { "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": ["express@5.2.1", "", { "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": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
+
     "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
     "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
 
 
     "opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
     "opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "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]> = {
   const TOOL: Record<string, [string, string]> = {
     todowrite: ["Todo", "\x1b[33m\x1b[1m"],
     todowrite: ["Todo", "\x1b[33m\x1b[1m"],
-    todoread: ["Todo", "\x1b[33m\x1b[1m"],
     bash: ["Bash", "\x1b[31m\x1b[1m"],
     bash: ["Bash", "\x1b[31m\x1b[1m"],
     edit: ["Edit", "\x1b[32m\x1b[1m"],
     edit: ["Edit", "\x1b[32m\x1b[1m"],
     glob: ["Glob", "\x1b[34m\x1b[1m"],
     glob: ["Glob", "\x1b[34m\x1b[1m"],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "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="
   }
   }
 }
 }

+ 2 - 1
packages/app/playwright.config.ts

@@ -6,7 +6,8 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
 const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
 const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
 const reuse = !process.env.CI
 const reuse = !process.env.CI
-const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
+const workers =
+  Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? (process.platform === "win32" ? 2 : 5) : 0)) || undefined
 
 
 export default defineConfig({
 export default defineConfig({
   testDir: "./e2e",
   testDir: "./e2e",

+ 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 { File } from "@opencode-ai/ui/file"
 import { Font } from "@opencode-ai/ui/font"
 import { Font } from "@opencode-ai/ui/font"
 import { Splash } from "@opencode-ai/ui/logo"
 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 { MetaProvider } from "@solidjs/meta"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { HighlightsProvider } from "@/context/highlights"
 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 { LayoutProvider } from "@/context/layout"
 import { ModelsProvider } from "@/context/models"
 import { ModelsProvider } from "@/context/models"
 import { NotificationProvider } from "@/context/notification"
 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 (
   return (
     <MetaProvider>
     <MetaProvider>
       <Font />
       <Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
           void window.api?.setTitlebar?.({ mode })
           void window.api?.setTitlebar?.({ mode })
         }}
         }}
       >
       >
-        <LanguageProvider>
+        <LanguageProvider locale={props.locale}>
           <UiI18nBridge>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
               <QueryProvider>
               <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 { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/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 { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 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 { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
 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 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({
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
     authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
       index: 0,
       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>) => {
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       if (!prompt.when) return true
       if (!prompt.when) return true
       const actual = value[prompt.when.key]
       const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
     listRef?.onKeyDown(e)
     listRef?.onKeyDown(e)
   }
   }
 
 
-  onMount(() => {
+  let auto = false
+  createEffect(() => {
+    if (auto) return
+    if (loading()) return
     if (methods().length === 1) {
     if (methods().length === 1) {
+      auto = true
       selectMethod(0)
       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 class="px-2.5 pb-10 flex flex-col gap-6">
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
             <Switch>
             <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}>
               <Match when={store.methodIndex === undefined}>
                 <MethodSelection />
                 <MethodSelection />
               </Match>
               </Match>

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

@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const open = recent()
       const open = recent()
       const seen = new Set(open)
       const seen = new Set(open)
       const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
       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 paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))
         .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 { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 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 { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
 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 { Link } from "./link"
 import { SettingsList } from "./settings-list"
 import { SettingsList } from "./settings-list"
 
 
 let demoSoundState = {
 let demoSoundState = {
   cleanup: undefined as (() => void) | undefined,
   cleanup: undefined as (() => void) | undefined,
   timeout: undefined as NodeJS.Timeout | 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,
 // 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.
 // delay the playback by 100ms during quick selection changes and pause existing sounds.
 const stopDemoSound = () => {
 const stopDemoSound = () => {
+  demoSoundState.run += 1
   if (demoSoundState.cleanup) {
   if (demoSoundState.cleanup) {
     demoSoundState.cleanup()
     demoSoundState.cleanup()
   }
   }
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
   demoSoundState.cleanup = undefined
   demoSoundState.cleanup = undefined
 }
 }
 
 
-const playDemoSound = (src: string | undefined) => {
+const playDemoSound = (id: string | undefined) => {
   stopDemoSound()
   stopDemoSound()
-  if (!src) return
+  if (!id) return
 
 
+  const run = ++demoSoundState.run
   demoSoundState.timeout = setTimeout(() => {
   demoSoundState.timeout = setTimeout(() => {
-    demoSoundState.cleanup = playSound(src)
+    void playSoundById(id).then((cleanup) => {
+      if (demoSoundState.run !== run) {
+        cleanup?.()
+        return
+      }
+      demoSoundState.cleanup = cleanup
+    })
   }, 100)
   }, 100)
 }
 }
 
 
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
   const platform = usePlatform()
   const platform = usePlatform()
   const settings = useSettings()
   const settings = useSettings()
 
 
+  onMount(() => {
+    void theme.loadThemes()
+  })
+
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     checking: false,
     checking: false,
   })
   })
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
       .finally(() => setStore("checking", false))
       .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 }[] => [
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
   ] as const
   ] as const
   const fontOptionsList = [...fontOptions]
   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 soundOptions = [noneSound, ...SOUND_OPTIONS]
 
 
   const soundSelectProps = (
   const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
       if (!option) return
-      playDemoSound(option.src)
+      playDemoSound(option.id === "none" ? undefined : option.id)
     },
     },
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
       if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
       }
       }
       setEnabled(true)
       setEnabled(true)
       set(option.id)
       set(option.id)
-      playDemoSound(option.src)
+      playDemoSound(option.id)
     },
     },
     variant: "secondary" as const,
     variant: "secondary" as const,
     size: "small" as const,
     size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
             current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
             current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
             value={(o) => o.value}
             value={(o) => o.value}
             label={(o) => language.t(o.label)}
             label={(o) => language.t(o.label)}
+            onHighlight={(option) => {
+              void loadFont().then((x) => x.ensureMonoFont(option?.value))
+            }}
             onSelect={(option) => option && settings.appearance.setFont(option.value)}
             onSelect={(option) => option && settings.appearance.setFont(option.value)}
             variant="secondary"
             variant="secondary"
             size="small"
             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 { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
 import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-import { DialogSelectServer } from "./dialog-select-server"
 
 
 const pollMs = 10_000
 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 checkServerHealth = useCheckServerHealth()
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
 
   createEffect(() => {
   createEffect(() => {
+    if (!enabled()) {
+      setStatus(reconcile({}))
+      return
+    }
     const list = servers()
     const list = servers()
     let dead = false
     let dead = false
 
 
@@ -162,6 +165,12 @@ export function StatusPopover() {
   const navigate = useNavigate()
   const navigate = useNavigate()
 
 
   const [shown, setShown] = createSignal(false)
   const [shown, setShown] = createSignal(false)
+  let dialogRun = 0
+  let dialogDead = false
+  onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
+  })
   const servers = createMemo(() => {
   const servers = createMemo(() => {
     const current = server.current
     const current = server.current
     const list = server.list
     const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
     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 sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const toggleMcp = useMcpToggleMutation()
   const toggleMcp = useMcpToggleMutation()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
                 <Button
                 <Button
                   variant="secondary"
                   variant="secondary"
                   class="mt-3 self-start h-8 px-3 py-1.5"
                   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")}
                   {language.t("status.popover.action.manageServers")}
                 </Button>
                 </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 { showToast } from "@opencode-ai/ui/toast"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
 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 { Icon } from "@opencode-ai/ui/icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 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 { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 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)
     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", () => {
   test("formatKeybind returns human readable output", () => {
     const display = formatKeybind("ctrl+alt+arrowup")
     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"
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
 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 { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
 
 
   let active = true
   let active = true
   let projectWritten = false
   let projectWritten = false
+  let bootedAt = 0
+  let bootingRoot = false
 
 
   onCleanup(() => {
   onCleanup(() => {
     active = false
     active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
       const sdk = sdkFor(directory)
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
       await bootstrapDirectory({
         directory,
         directory,
+        global: {
+          config: globalStore.config,
+          project: globalStore.project,
+          provider: globalStore.provider,
+        },
         sdk,
         sdk,
         store: child[0],
         store: child[0],
         setStore: child[1],
         setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
   const unsub = globalSDK.event.listen((e) => {
   const unsub = globalSDK.event.listen((e) => {
     const directory = e.name
     const directory = e.name
     const event = e.details
     const event = e.details
+    const recent = bootingRoot || Date.now() - bootedAt < 1500
 
 
     if (directory === "global") {
     if (directory === "global") {
       applyGlobalEvent({
       applyGlobalEvent({
         event,
         event,
         project: globalStore.project,
         project: globalStore.project,
-        refresh: queue.refresh,
+        refresh: () => {
+          if (recent) return
+          queue.refresh()
+        },
         setGlobalProject: setProjects,
         setGlobalProject: setProjects,
       })
       })
       if (event.type === "server.connected" || event.type === "global.disposed") {
       if (event.type === "server.connected" || event.type === "global.disposed") {
+        if (recent) return
         for (const directory of Object.keys(children.children)) {
         for (const directory of Object.keys(children.children)) {
           queue.push(directory)
           queue.push(directory)
         }
         }
@@ -325,17 +327,19 @@ function createGlobalSync() {
   })
   })
 
 
   async function bootstrap() {
   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(() => {
   onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
 
 
 export function GlobalSyncProvider(props: ParentProps) {
 export function GlobalSyncProvider(props: ParentProps) {
   const value = createGlobalSync()
   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() {
 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 { batch } from "solid-js"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
 import type { State, VcsCache } from "./types"
-import { cmp, normalizeProviderList } from "./utils"
+import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { formatServerError } from "@/utils/server-errors"
 
 
 type GlobalStore = {
 type GlobalStore = {
@@ -31,73 +31,102 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
   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: {
 export async function bootstrapGlobal(input: {
   globalSDK: OpencodeClient
   globalSDK: OpencodeClient
-  connectErrorTitle: string
-  connectErrorDescription: string
   requestFailedTitle: string
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
   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)
   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: {
 export async function bootstrapDirectory(input: {
   directory: string
   directory: string
   sdk: OpencodeClient
   sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
   vcsCache: VcsCache
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
   loadSessions: (directory: string) => Promise<void> | void
   translate: (key: string, vars?: Record<string, string | number>) => string
   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)
     const project = getFilename(input.directory)
     showToast({
     showToast({
       variant: "error",
       variant: "error",
       title: input.translate("toast.project.reloadFailed.title", { project }),
       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", () => {
   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({
     applyDirectoryEvent({
       event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
       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", () => {
   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
       break
     }
     }
     case "vcs.branch.updated": {
     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
       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)
       input.setStore("vcs", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       break
       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)
 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 {
 export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
   return {
   return {
     ...input,
     ...input,

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

@@ -1,42 +1,10 @@
 import * as i18n from "@solid-primitives/i18n"
 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 { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import { dict as en } from "@/i18n/en"
 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 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 =
 export type Locale =
   | "en"
   | "en"
@@ -59,6 +27,7 @@ export type Locale =
 
 
 type RawDictionary = typeof en & typeof uiEn
 type RawDictionary = typeof en & typeof uiEn
 type Dictionary = i18n.Flatten<RawDictionary>
 type Dictionary = i18n.Flatten<RawDictionary>
+type Source = { dict: Record<string, string> }
 
 
 function cookie(locale: Locale) {
 function cookie(locale: Locale) {
   return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
   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 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 }> = [
 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") },
   { 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 {
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
   if (typeof navigator !== "object") return "en"
 
 
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
   return "en"
   return "en"
 }
 }
 
 
-function normalizeLocale(value: string): Locale {
+export function normalizeLocale(value: string): Locale {
   return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
   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({
 export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
   name: "Language",
   name: "Language",
-  init: () => {
+  init: (props: { locale?: Locale }) => {
+    const initial = props.locale ?? readStoredLocale() ?? detectLocale()
     const [store, setStore, _, ready] = persisted(
     const [store, setStore, _, ready] = persisted(
       Persist.global("language", ["language.v1"]),
       Persist.global("language", ["language.v1"]),
       createStore({
       createStore({
-        locale: detectLocale() as Locale,
+        locale: initial,
       }),
       }),
     )
     )
 
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
-    console.log("locale", locale())
     const intl = createMemo(() => INTL[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])
     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 { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 
 
 type NotificationBase = {
 type NotificationBase = {
   directory?: string
   directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session.parentID) return
         if (session.parentID) return
 
 
         if (settings.sounds.agentEnabled()) {
         if (settings.sounds.agentEnabled()) {
-          playSound(soundSrc(settings.sounds.agent()))
+          void playSoundById(settings.sounds.agent())
         }
         }
 
 
         append({
         append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session?.parentID) return
         if (session?.parentID) return
 
 
         if (settings.sounds.errorsEnabled()) {
         if (settings.sounds.errorsEnabled()) {
-          playSound(soundSrc(settings.sounds.errors()))
+          void playSoundById(settings.sounds.errors())
         }
         }
 
 
         const error = "error" in event.properties ? event.properties.error : undefined
         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)
   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({
 export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
   name: "Settings",
   name: "Settings",
   init: () => {
   init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
 
 
     createEffect(() => {
     createEffect(() => {
       if (typeof document === "undefined") return
       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 {
     return {

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

@@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
       return globalSync.child(directory)
     }
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
     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 inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = 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
             const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
             if (cached && hasSession && !opts?.force) return
             if (cached && hasSession && !opts?.force) return
 
 
-            const limit = meta.limit[key] ?? messagePageSize
+            const limit = meta.limit[key] ?? initialMessagePageSize
             const sessionReq =
             const sessionReq =
               hasSession && !opts?.force
               hasSession && !opts?.force
                 ? Promise.resolve()
                 ? Promise.resolve()
@@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const [, setStore] = globalSync.child(directory)
             const [, setStore] = globalSync.child(directory)
             touch(directory, setStore, sessionID)
             touch(directory, setStore, sessionID)
             const key = keyFor(directory, sessionID)
             const key = keyFor(directory, sessionID)
-            const step = count ?? messagePageSize
+            const step = count ?? historyMessagePageSize
             if (meta.loading[key]) return
             if (meta.loading[key]) return
             if (meta.complete[key]) return
             if (meta.complete[key]) return
             const before = meta.cursor[key]
             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) {
 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) {
 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 = () => {
   const providers = () => {
     if (dir()) {
     if (dir()) {
       const [projectStore] = globalSync.child(dir())
       const [projectStore] = globalSync.child(dir())
-      return projectStore.provider
+      if (projectStore.provider.all.length > 0) return projectStore.provider
     }
     }
     return globalSync.data.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.skill.description": "تحميل مهارة بالاسم",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
   "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
-  "settings.permissions.tool.todoread.title": "قراءة المهام",
-  "settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
   "settings.permissions.tool.todowrite.title": "كتابة المهام",
   "settings.permissions.tool.todowrite.title": "كتابة المهام",
   "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
   "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
   "settings.permissions.tool.webfetch.title": "جلب الويب",
   "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.skill.description": "Carregar uma habilidade por nome",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
   "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.title": "Escrever Tarefas",
   "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
   "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
   "settings.permissions.tool.webfetch.title": "Buscar Web",
   "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.skill.description": "Učitaj vještinu po nazivu",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
   "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.title": "Ažuriranje liste zadataka",
   "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
   "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
   "settings.permissions.tool.webfetch.title": "Web preuzimanje",
   "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.skill.description": "Indlæs en færdighed efter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
   "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.title": "Skriv To-do",
   "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
   "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
   "settings.permissions.tool.webfetch.title": "Webhentning",
   "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.skill.description": "Eine Fähigkeit nach Namen laden",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
   "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.title": "Todo schreiben",
   "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
   "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
   "settings.permissions.tool.webfetch.title": "Web-Abruf",
   "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.noVcs.createGit.action": "Create Git repository",
   "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
   "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
   "session.review.noChanges": "No changes",
   "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.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
   "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.skill.description": "Load a skill by name",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Run language server queries",
   "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.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Update the todo list",
   "settings.permissions.tool.todowrite.description": "Update the todo list",
   "settings.permissions.tool.webfetch.title": "Web Fetch",
   "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.skill.description": "Cargar una habilidad por nombre",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
   "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.title": "Escribir Todo",
   "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
   "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
   "settings.permissions.tool.webfetch.title": "Web Fetch",
   "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.skill.description": "Charger une compétence par son nom",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
   "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.title": "Écrire Todo",
   "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
   "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
   "settings.permissions.tool.webfetch.title": "Récupération Web",
   "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.skill.description": "名前によるスキルの読み込み",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
   "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.title": "Todo書き込み",
   "settings.permissions.tool.todowrite.description": "Todoリストの更新",
   "settings.permissions.tool.todowrite.description": "Todoリストの更新",
   "settings.permissions.tool.webfetch.title": "Web取得",
   "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.skill.description": "이름으로 기술 로드",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
   "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
-  "settings.permissions.tool.todoread.title": "할 일 읽기",
-  "settings.permissions.tool.todoread.description": "할 일 목록 읽기",
   "settings.permissions.tool.todowrite.title": "할 일 쓰기",
   "settings.permissions.tool.todowrite.title": "할 일 쓰기",
   "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
   "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
   "settings.permissions.tool.webfetch.title": "웹 가져오기",
   "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.skill.description": "Last en ferdighet etter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
   "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.title": "Skriv gjøremål",
   "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
   "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
   "settings.permissions.tool.webfetch.title": "Webhenting",
   "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.skill.description": "Ładowanie umiejętności według nazwy",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
   "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.title": "Zapis Todo",
   "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
   "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
   "settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
   "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.skill.description": "Загрузка навыка по имени",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
   "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.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Обновление списка задач",
   "settings.permissions.tool.todowrite.description": "Обновление списка задач",
   "settings.permissions.tool.webfetch.title": "Web Fetch",
   "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.skill.description": "โหลดทักษะตามชื่อ",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
   "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
-  "settings.permissions.tool.todoread.title": "อ่านรายการงาน",
-  "settings.permissions.tool.todoread.description": "อ่านรายการงาน",
   "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
   "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
   "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
   "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
   "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
   "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.skill.description": "Ada göre bir beceri yükle",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
   "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.title": "Görev Yaz",
   "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
   "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
   "settings.permissions.tool.webfetch.title": "Web Getir",
   "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.skill.description": "按名称加载技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "运行语言服务器查询",
   "settings.permissions.tool.lsp.description": "运行语言服务器查询",
-  "settings.permissions.tool.todoread.title": "读取待办",
-  "settings.permissions.tool.todoread.description": "读取待办列表",
   "settings.permissions.tool.todowrite.title": "更新待办",
   "settings.permissions.tool.todowrite.title": "更新待办",
   "settings.permissions.tool.todowrite.description": "更新待办列表",
   "settings.permissions.tool.todowrite.description": "更新待办列表",
   "settings.permissions.tool.webfetch.title": "网页获取",
   "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.skill.description": "按名稱載入技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
   "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
-  "settings.permissions.tool.todoread.title": "讀取待辦",
-  "settings.permissions.tool.todoread.description": "讀取待辦清單",
   "settings.permissions.tool.todowrite.title": "更新待辦",
   "settings.permissions.tool.todowrite.title": "更新待辦",
   "settings.permissions.tool.todowrite.description": "更新待辦清單",
   "settings.permissions.tool.todowrite.description": "更新待辦清單",
   "settings.permissions.tool.webfetch.title": "Web Fetch",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

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

@@ -1,6 +1,7 @@
 export { AppBaseProviders, AppInterface } from "./app"
 export { AppBaseProviders, AppInterface } from "./app"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
 export { useCommand } from "./context/command"
+export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { ServerConnection } from "./context/server"
 export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"
 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 { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
 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 { useLanguage } from "@/context/language"
 import { LocalProvider } from "@/context/local"
 import { LocalProvider } from "@/context/local"
 import { SDKProvider } from "@/context/sdk"
 import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
 
 
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+  const location = useLocation()
   const navigate = useNavigate()
   const navigate = useNavigate()
   const sync = useSync()
   const sync = useSync()
   const slug = createMemo(() => base64Encode(props.directory))
   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 (
   return (
     <DataProvider
     <DataProvider
       data={sync.data}
       data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 
 
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const params = useParams()
-  const location = useLocation()
   const language = useLanguage()
   const language = useLanguage()
-  const globalSDK = useGlobalSDK()
   const navigate = useNavigate()
   const navigate = useNavigate()
   let invalid = ""
   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 (
   return (
     <Show when={resolved()} keyed>
     <Show when={resolved()} keyed>

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

@@ -113,6 +113,14 @@ export default function Home() {
             </ul>
             </ul>
           </div>
           </div>
         </Match>
         </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}>
         <Match when={true}>
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
             <Icon name="folder-add-left" size="large" />
             <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 { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { retry } from "@opencode-ai/util/retry"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
 import { createAim } from "@/utils/aim"
 import { setNavigate } from "@/utils/notification-click"
 import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 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 { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 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 { DebugBar } from "@/components/debug-bar"
 import { Titlebar } from "@/components/titlebar"
 import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
   const pageReady = createMemo(() => ready())
   const pageReady = createMemo(() => ready())
 
 
   let scrollContainerRef: HTMLDivElement | undefined
   let scrollContainerRef: HTMLDivElement | undefined
+  let dialogRun = 0
+  let dialogDead = false
 
 
   const params = useParams()
   const params = useParams()
   const globalSDK = useGlobalSDK()
   const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
       dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
       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 colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
     system: "theme.scheme.system",
     system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
   })
   })
 
 
   onCleanup(() => {
   onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     clearTimeout(sortNowTimeout)
     clearTimeout(sortNowTimeout)
     if (sortNowInterval) clearInterval(sortNowInterval)
     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 nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
     const nextThemeId = ids[nextIndex]
     const nextThemeId = ids[nextIndex]
     theme.setTheme(nextThemeId)
     theme.setTheme(nextThemeId)
-    const nextTheme = theme.themes()[nextThemeId]
     showToast({
     showToast({
       title: language.t("toast.theme.title"),
       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 (e.details.type === "permission.asked") {
           if (settings.sounds.permissionsEnabled()) {
           if (settings.sounds.permissionsEnabled()) {
-            playSound(soundSrc(settings.sounds.permissions()))
+            void playSoundById(settings.sounds.permissions())
           }
           }
           if (settings.notifications.permissions()) {
           if (settings.notifications.permissions()) {
             void platform.notify(title, description, href)
             void platform.notify(title, description, href)
@@ -967,6 +965,8 @@ export default function Layout(props: ParentProps) {
         : projects[(index + offset + projects.length) % projects.length]
         : projects[(index + offset + projects.length) % projects.length]
     if (!target) return
     if (!target) return
 
 
+    // warm up child store to prevent flicker
+    globalSync.child(target.worktree)
     openProject(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({
       commands.push({
         id: `theme.set.${id}`,
         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"),
         category: language.t("command.category.theme"),
         onSelect: () => theme.commitPreview(),
         onSelect: () => theme.commitPreview(),
         onHighlight: () => {
         onHighlight: () => {
@@ -1206,15 +1206,27 @@ export default function Layout(props: ParentProps) {
   })
   })
 
 
   function connectProvider() {
   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() {
   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() {
   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) {
   function projectRoot(directory: string) {
@@ -1441,7 +1453,13 @@ export default function Layout(props: ParentProps) {
     layout.sidebar.toggleWorkspaces(project.worktree)
     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() {
   async function chooseProject() {
     function resolve(result: string | string[] | null) {
     function resolve(result: string | string[] | null) {
@@ -1462,10 +1480,14 @@ export default function Layout(props: ParentProps) {
       })
       })
       resolve(result)
       resolve(result)
     } else {
     } 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 { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useMutation } from "@tanstack/solid-query"
 import { useMutation } from "@tanstack/solid-query"
 import {
 import {
@@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
 const emptyUserMessages: UserMessage[] = []
 const emptyUserMessages: UserMessage[] = []
 const emptyFollowups: (FollowupDraft & { id: string })[] = []
 const emptyFollowups: (FollowupDraft & { id: string })[] = []
 
 
+type ChangeMode = "git" | "branch" | "session" | "turn"
+type VcsMode = "git" | "branch"
+
 type SessionHistoryWindowInput = {
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
   sessionID: () => string | undefined
   messagesReady: () => boolean
   messagesReady: () => boolean
@@ -424,15 +427,16 @@ export default function Page() {
 
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   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 reviewTab = createMemo(() => isDesktop())
   const tabState = createSessionTabs({
   const tabState = createSessionTabs({
     tabs,
     tabs,
     pathFromTab: file.pathFromTab,
     pathFromTab: file.pathFromTab,
     normalizeTab,
     normalizeTab,
     review: reviewTab,
     review: reviewTab,
-    hasReview,
+    hasReview: canReview,
   })
   })
   const contextOpen = tabState.contextOpen
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
   const openedTabs = tabState.openedTabs
@@ -455,6 +459,12 @@ export default function Page() {
     if (!id) return false
     if (!id) return false
     return sync.session.history.loading(id)
     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(
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
     () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -508,11 +518,22 @@ export default function Page() {
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
     messageId: undefined as string | undefined,
     mobileTab: "session" as "session" | "changes",
     mobileTab: "session" as "session" | "changes",
-    changes: "session" as "session" | "turn",
+    changes: "git" as ChangeMode,
     newSessionWorktree: "main",
     newSessionWorktree: "main",
     deferRender: false,
     deferRender: false,
   })
   })
 
 
+  const [vcs, setVcs] = createStore({
+    diff: {
+      git: [] as FileDiff[],
+      branch: [] as FileDiff[],
+    },
+    ready: {
+      git: false,
+      branch: false,
+    },
+  })
+
   const [followup, setFollowup] = createStore({
   const [followup, setFollowup] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
     failed: {} as Record<string, string | undefined>,
     failed: {} as Record<string, string | undefined>,
@@ -539,6 +560,68 @@ export default function Page() {
   let refreshTimer: number | undefined
   let refreshTimer: number | undefined
   let diffFrame: number | undefined
   let diffFrame: number | undefined
   let diffTimer: 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) => {
   createComputed((prev) => {
     const open = desktopReviewOpen()
     const open = desktopReviewOpen()
@@ -554,7 +637,42 @@ export default function Page() {
   }, desktopReviewOpen())
   }, desktopReviewOpen())
 
 
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
   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(() => {
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
     if (store.newSessionWorktree === "create") return "create"
@@ -620,13 +738,7 @@ export default function Page() {
     scrollToMessage(msgs[targetIndex], "auto")
     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
     const project = sync.project
     if (project && !project.vcs) return "session.review.noVcs"
     if (project && !project.vcs) return "session.review.noVcs"
     if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
     if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -748,13 +860,46 @@ export default function Page() {
       sessionKey,
       sessionKey,
       () => {
       () => {
         setStore("messageId", undefined)
         setStore("messageId", undefined)
-        setStore("changes", "session")
+        setStore("changes", "git")
         setUi("pendingMessage", undefined)
         setUi("pendingMessage", undefined)
       },
       },
       { defer: true },
       { 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(
   createEffect(
     on(
     on(
       () => params.dir,
       () => params.dir,
@@ -877,6 +1022,40 @@ export default function Page() {
   }
   }
 
 
   const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
   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 fileTreeTab = () => layout.fileTree.tab()
   const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
   const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -923,21 +1102,23 @@ export default function Page() {
     loadFile: file.load,
     loadFile: file.load,
   })
   })
 
 
-  const changesOptions = ["session", "turn"] as const
-  const changesOptionsList = [...changesOptions]
-
   const changesTitle = () => {
   const changesTitle = () => {
-    if (!hasReview()) {
+    if (!canReview()) {
       return null
       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 (
     return (
       <Select
       <Select
-        options={changesOptionsList}
+        options={changesOptions()}
         current={store.changes}
         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)}
         onSelect={(option) => option && setStore("changes", option)}
         variant="ghost"
         variant="ghost"
         size="small"
         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="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>
     </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 }) => {
   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>
       return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
     }
     }
 
 
-    if (reviewEmptyKey() === "session.review.noVcs") {
+    if (sessionEmptyKey() === "session.review.noVcs") {
       return (
       return (
         <div class={input.emptyClass}>
         <div class={input.emptyClass}>
           <div class="flex flex-col gap-3">
           <div class="flex flex-col gap-3">
@@ -979,7 +1174,7 @@ export default function Page() {
 
 
     return (
     return (
       <div class={input.emptyClass}>
       <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>
       </div>
     )
     )
   }
   }
@@ -1083,7 +1278,7 @@ export default function Page() {
     const pending = tree.pendingDiff
     const pending = tree.pendingDiff
     if (!pending) return
     if (!pending) return
     if (!tree.reviewScroll) return
     if (!tree.reviewScroll) return
-    if (!diffsReady()) return
+    if (!reviewReady()) return
 
 
     const attempt = (count: number) => {
     const attempt = (count: number) => {
       if (tree.pendingDiff !== pending) return
       if (tree.pendingDiff !== pending) return
@@ -1124,10 +1319,7 @@ export default function Page() {
     const id = params.id
     const id = params.id
     if (!id) return
     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.data.session_diff[id] !== undefined) return
     if (sync.status === "loading") return
     if (sync.status === "loading") return
 
 
@@ -1136,13 +1328,7 @@ export default function Page() {
 
 
   createEffect(
   createEffect(
     on(
     on(
-      () =>
-        [
-          sessionKey(),
-          isDesktop()
-            ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
-            : store.mobileTab === "changes",
-        ] as const,
+      () => [sessionKey(), wantsReview()] as const,
       ([key, wants]) => {
       ([key, wants]) => {
         if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
         if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
         if (diffTimer !== undefined) window.clearTimeout(diffTimer)
         if (diffTimer !== undefined) window.clearTimeout(diffTimer)
@@ -1184,8 +1370,6 @@ export default function Page() {
     on(
     on(
       () => sdk.directory,
       () => sdk.directory,
       () => {
       () => {
-        void file.tree.list("")
-
         const tab = activeFileTab()
         const tab = activeFileTab()
         if (!tab) return
         if (!tab) return
         const path = file.pathFromTab(tab)
         const path = file.pathFromTab(tab)
@@ -1640,6 +1824,9 @@ export default function Page() {
     sessionID: () => params.id,
     sessionID: () => params.id,
     messagesReady,
     messagesReady,
     visibleUserMessages,
     visibleUserMessages,
+    historyMore,
+    historyLoading,
+    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
     turnStart: historyWindow.turnStart,
     turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
     pendingMessage: () => ui.pendingMessage,
@@ -1711,7 +1898,7 @@ export default function Page() {
           <div class="flex-1 min-h-0 overflow-hidden">
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
             <Switch>
               <Match when={params.id}>
               <Match when={params.id}>
-                <Show when={lastUserMessage()}>
+                <Show when={messagesReady()}>
                   <MessageTimeline
                   <MessageTimeline
                     mobileChanges={mobileChanges()}
                     mobileChanges={mobileChanges()}
                     mobileFallback={reviewContent({
                     mobileFallback={reviewContent({
@@ -1827,6 +2014,12 @@ export default function Page() {
         </div>
         </div>
 
 
         <SessionSidePanel
         <SessionSidePanel
+          canReview={canReview}
+          diffs={reviewDiffs}
+          diffsReady={reviewReady}
+          empty={reviewEmptyText}
+          hasReview={hasReview}
+          reviewCount={reviewCount}
           reviewPanel={reviewPanel}
           reviewPanel={reviewPanel}
           activeDiff={tree.activeDiff}
           activeDiff={tree.activeDiff}
           focusReviewDiff={focusReviewDiff}
           focusReviewDiff={focusReviewDiff}

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

@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
             </Show>
             </Show>
             <div
             <div
               role="log"
               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={{
               classList={{
                 "w-full": true,
                 "w-full": true,
                 "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
                 "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 { Mark } from "@opencode-ai/ui/logo"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } 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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 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 { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
-import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { FileTabContent } from "@/pages/session/file-tabs"
 import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 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"
 import { useSessionLayout } from "@/pages/session/session-layout"
 
 
 export function SessionSidePanel(props: {
 export function SessionSidePanel(props: {
+  canReview: () => boolean
+  diffs: () => FileDiff[]
+  diffsReady: () => boolean
+  empty: () => string
+  hasReview: () => boolean
+  reviewCount: () => number
   reviewPanel: () => JSX.Element
   reviewPanel: () => JSX.Element
   activeDiff?: string
   activeDiff?: string
   focusReviewDiff: (path: string) => void
   focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
   size: Sizing
   size: Sizing
 }) {
 }) {
   const layout = useLayout()
   const layout = useLayout()
-  const sync = useSync()
   const file = useFile()
   const file = useFile()
   const language = useLanguage()
   const language = useLanguage()
   const command = useCommand()
   const command = useCommand()
   const dialog = useDialog()
   const dialog = useDialog()
-  const { params, sessionKey, tabs, view } = useSessionLayout()
+  const { sessionKey, tabs, view } = useSessionLayout()
 
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const isDesktop = createMediaQuery("(min-width: 768px)")
 
 
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
   })
   })
   const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
   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 kinds = createMemo(() => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
     const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
       if (!a) return b
       if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
     const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
     const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
 
 
     const out = new Map<string, "add" | "del" | "mix">()
     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 file = normalize(diff.file)
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
       const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
 
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
     pathFromTab: file.pathFromTab,
     pathFromTab: file.pathFromTab,
     normalizeTab,
     normalizeTab,
     review: reviewTab,
     review: reviewTab,
-    hasReview,
+    hasReview: props.canReview,
   })
   })
   const contextOpen = tabState.contextOpen
   const contextOpen = tabState.contextOpen
   const openedTabs = tabState.openedTabs
   const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
                         onCleanup(stop)
                         onCleanup(stop)
                       }}
                       }}
                     >
                     >
-                      <Show when={reviewTab()}>
+                      <Show when={reviewTab() && props.canReview()}>
                         <Tabs.Trigger value="review">
                         <Tabs.Trigger value="review">
                           <div class="flex items-center gap-1.5">
                           <div class="flex items-center gap-1.5">
                             <div>{language.t("session.tab.review")}</div>
                             <div>{language.t("session.tab.review")}</div>
-                            <Show when={hasReview()}>
-                              <div>{reviewCount()}</div>
+                            <Show when={props.hasReview()}>
+                              <div>{props.reviewCount()}</div>
                             </Show>
                             </Show>
                           </div>
                           </div>
                         </Tabs.Trigger>
                         </Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
                     </Tabs.List>
                     </Tabs.List>
                   </div>
                   </div>
 
 
-                  <Show when={reviewTab()}>
+                  <Show when={reviewTab() && props.canReview()}>
                     <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                     <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                       <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
                       <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
                     </Tabs.Content>
                     </Tabs.Content>
@@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
               >
               >
                 <Tabs.List>
                 <Tabs.List>
                   <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
                   <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>
                   <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                   <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
                     {language.t("session.files.all")}
                     {language.t("session.files.all")}
@@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
                 </Tabs.List>
                 </Tabs.List>
                 <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
                 <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
                   <Switch>
                   <Switch>
-                    <Match when={hasReview()}>
+                    <Match when={props.hasReview() || !props.diffsReady()}>
                       <Show
                       <Show
-                        when={diffsReady()}
+                        when={props.diffsReady()}
                         fallback={
                         fallback={
                           <div class="px-2 py-2 text-12-regular text-text-weak">
                           <div class="px-2 py-2 text-12-regular text-text-weak">
                             {language.t("common.loading")}
                             {language.t("common.loading")}
@@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
                         />
                         />
                       </Show>
                       </Show>
                     </Match>
                     </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>
                   </Switch>
                 </Tabs.Content>
                 </Tabs.Content>
                 <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
                 <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
     if (!id) return
     return sync.session.get(id)
     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) => {
   const normalizeTab = (tab: string) => {
     if (!tab.startsWith("file://")) return tab
     if (!tab.startsWith("file://")) return tab
     return file.tab(tab)
     return file.tab(tab)
@@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.previous",
         id: "message.previous",
         title: language.t("command.message.previous"),
         title: language.t("command.message.previous"),
         description: language.t("command.message.previous.description"),
         description: language.t("command.message.previous.description"),
-        keybind: "mod+arrowup",
+        keybind: "mod+alt+[",
         disabled: !params.id,
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(-1),
         onSelect: () => navigateMessageByOffset(-1),
       }),
       }),
@@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.next",
         id: "message.next",
         title: language.t("command.message.next"),
         title: language.t("command.message.next"),
         description: language.t("command.message.next.description"),
         description: language.t("command.message.next.description"),
-        keybind: "mod+arrowdown",
+        keybind: "mod+alt+]",
         disabled: !params.id,
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(1),
         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
   sessionID: () => string | undefined
   messagesReady: () => boolean
   messagesReady: () => boolean
   visibleUserMessages: () => UserMessage[]
   visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
   turnStart: () => number
   turnStart: () => number
   currentMessageId: () => string | undefined
   currentMessageId: () => string | undefined
   pendingMessage: () => string | undefined
   pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
     queue(() => scrollToMessage(msg, "auto"))
     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(() => {
   onMount(() => {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
       window.history.scrollRestoration = "manual"
       window.history.scrollRestoration = "manual"

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

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
 const defaultTimeoutMs = 3000
 const defaultTimeoutMs = 3000
 const defaultRetryCount = 2
 const defaultRetryCount = 2
 const defaultRetryDelayMs = 100
 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) {
 function timeoutSignal(timeoutMs: number) {
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
   const platform = usePlatform()
   const platform = usePlatform()
   const fetcher = platform.fetch ?? globalThis.fetch
   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 = [
 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
 ] as const
 
 
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundID = SoundOption["id"]
 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) {
 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) {
 export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
   if (!src) return
   if (!src) return
   const audio = new Audio(src)
   const audio = new Audio(src)
   audio.play().catch(() => undefined)
   audio.play().catch(() => undefined)
-
-  // Return a cleanup function to pause the sound.
   return () => {
   return () => {
     audio.pause()
     audio.pause()
     audio.currentTime = 0
     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 solidPlugin from "vite-plugin-solid"
 import tailwindcss from "@tailwindcss/vite"
 import tailwindcss from "@tailwindcss/vite"
 import { fileURLToPath } from "url"
 import { fileURLToPath } from "url"
 
 
+const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
+
 /**
 /**
  * @type {import("vite").PluginOption}
  * @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(),
   tailwindcss(),
   solidPlugin(),
   solidPlugin(),
 ]
 ]

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

@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
     "open-directory-picker",
     "open-directory-picker",
     async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
     async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
       const result = await dialog.showOpenDialog({
       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",
         title: opts?.title ?? "Choose a folder",
         defaultPath: opts?.defaultPath,
         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) {
 export function getStore(name = SETTINGS_STORE) {
   const cached = cache.get(name)
   const cached = cache.get(name)
   if (cached) return cached
   if (cached) return cached
-  const next = new Store({ name })
+  const next = new Store({ name, fileExtension: "" })
   cache.set(name, next)
   cache.set(name, next)
   return next
   return next
 }
 }

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

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppBaseProviders,
   AppInterface,
   AppInterface,
   handleNotificationClick,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   type Platform,
   PlatformProvider,
   PlatformProvider,
   ServerConnection,
   ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
 
 
 render(() => {
 render(() => {
   const platform = createPlatform()
   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())
   const [windowCount] = createResource(() => window.api.getWindowCount())
 
 
@@ -257,6 +271,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
     }),
   )
   )
+  const [locale] = createResource(loadLocale)
 
 
   const servers = () => {
   const servers = () => {
     const data = sidecar()
     const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
 
 
   return (
   return (
     <PlatformProvider value={platform}>
     <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 (
             return (
               <AppInterface
               <AppInterface
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 servers={servers()}
                 servers={servers()}
                 router={MemoryRouter}
                 router={MemoryRouter}
-                disableHealthCheck={(windowCount() ?? 0) > 1}
               >
               >
                 <Inner />
                 <Inner />
               </AppInterface>
               </AppInterface>

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

@@ -6,6 +6,9 @@ import {
   AppBaseProviders,
   AppBaseProviders,
   AppInterface,
   AppInterface,
   handleNotificationClick,
   handleNotificationClick,
+  loadLocaleDict,
+  normalizeLocale,
+  type Locale,
   type Platform,
   type Platform,
   PlatformProvider,
   PlatformProvider,
   ServerConnection,
   ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
 
 
 render(() => {
 render(() => {
   const platform = createPlatform()
   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)
   // Fetch sidecar credentials from Rust (available immediately, before health check)
   const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
   const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
       if (url) return ServerConnection.key({ type: "http", http: { url } })
       if (url) return ServerConnection.key({ type: "http", http: { url } })
     }),
     }),
   )
   )
+  const [locale] = createResource(loadLocale)
 
 
   // Build the sidecar server connection once credentials arrive
   // Build the sidecar server connection once credentials arrive
   const servers = () => {
   const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
 
 
   return (
   return (
     <PlatformProvider value={platform}>
     <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading}>
+      <AppBaseProviders locale={locale.latest}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
           {(_) => {
           {(_) => {
             return (
             return (
               <AppInterface
               <AppInterface

+ 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": []
+}

+ 2 - 2
packages/opencode/package.json

@@ -92,7 +92,7 @@
     "@effect/platform-node": "catalog:",
     "@effect/platform-node": "catalog:",
     "@hono/standard-validator": "0.1.5",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
     "@hono/zod-validator": "catalog:",
-    "@modelcontextprotocol/sdk": "1.25.2",
+    "@modelcontextprotocol/sdk": "1.27.1",
     "@octokit/graphql": "9.0.2",
     "@octokit/graphql": "9.0.2",
     "@octokit/rest": "catalog:",
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",
     "@openauthjs/openauth": "catalog:",
@@ -133,9 +133,9 @@
     "minimatch": "10.0.3",
     "minimatch": "10.0.3",
     "open": "10.1.2",
     "open": "10.1.2",
     "opencode-gitlab-auth": "2.0.0",
     "opencode-gitlab-auth": "2.0.0",
+    "opencode-poe-auth": "0.0.1",
     "opentui-spinner": "0.0.6",
     "opentui-spinner": "0.0.6",
     "partial-json": "0.1.7",
     "partial-json": "0.1.7",
-    "opencode-poe-auth": "0.0.1",
     "remeda": "catalog:",
     "remeda": "catalog:",
     "semver": "^7.6.3",
     "semver": "^7.6.3",
     "solid-js": "catalog:",
     "solid-js": "catalog:",

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

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

@@ -148,7 +148,6 @@ export namespace Agent {
               permission: Permission.merge(
               permission: Permission.merge(
                 defaults,
                 defaults,
                 Permission.fromConfig({
                 Permission.fromConfig({
-                  todoread: "deny",
                   todowrite: "deny",
                   todowrite: "deny",
                 }),
                 }),
                 user,
                 user,

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

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

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

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

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

@@ -1,8 +1,8 @@
 import { UI } from "../ui"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { cmd } from "./cmd"
+import { Git } from "@/git"
 import { Instance } from "@/project/instance"
 import { Instance } from "@/project/instance"
 import { Process } from "@/util/process"
 import { Process } from "@/util/process"
-import { git } from "@/util/git"
 
 
 export const PrCommand = cmd({
 export const PrCommand = cmd({
   command: "pr <number>",
   command: "pr <number>",
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
               const remoteName = forkOwner
               const remoteName = forkOwner
 
 
               // Check if remote already exists
               // 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)) {
               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,
                   cwd: Instance.worktree,
                 })
                 })
                 UI.println(`Added fork remote: ${remoteName}`)
                 UI.println(`Added fork remote: ${remoteName}`)
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
 
 
               // Set upstream to the fork so pushes go there
               // Set upstream to the fork so pushes go there
               const headRefName = prInfo.headRefName
               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,
                 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) {
     if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
       route.navigate({ type: "home" })
       route.navigate({ type: "home" })
       toast.show({
       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
     const error = evt.properties.error
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
     const message = (() => {
     const message = (() => {

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

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

+ 7 - 7
packages/opencode/src/file/index.ts

@@ -1,7 +1,7 @@
 import { BusEvent } from "@/bus/bus-event"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRunPromise } from "@/effect/run-service"
 import { makeRunPromise } from "@/effect/run-service"
-import { git } from "@/util/git"
+import { Git } from "@/git"
 import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
 import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import { formatPatch, structuredPatch } from "diff"
 import fs from "fs"
 import fs from "fs"
@@ -432,7 +432,7 @@ export namespace File {
 
 
         return yield* Effect.promise(async () => {
         return yield* Effect.promise(async () => {
           const diffOutput = (
           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,
               cwd: Instance.directory,
             })
             })
           ).text()
           ).text()
@@ -452,7 +452,7 @@ export namespace File {
           }
           }
 
 
           const untrackedOutput = (
           const untrackedOutput = (
-            await git(
+            await Git.run(
               [
               [
                 "-c",
                 "-c",
                 "core.fsmonitor=false",
                 "core.fsmonitor=false",
@@ -485,7 +485,7 @@ export namespace File {
           }
           }
 
 
           const deletedOutput = (
           const deletedOutput = (
-            await git(
+            await Git.run(
               [
               [
                 "-c",
                 "-c",
                 "core.fsmonitor=false",
                 "core.fsmonitor=false",
@@ -576,17 +576,17 @@ export namespace File {
 
 
           if (Instance.project.vcs === "git") {
           if (Instance.project.vcs === "git") {
             let diff = (
             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()
             ).text()
             if (!diff.trim()) {
             if (!diff.trim()) {
               diff = (
               diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
                   cwd: Instance.directory,
                   cwd: Instance.directory,
                 })
                 })
               ).text()
               ).text()
             }
             }
             if (diff.trim()) {
             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", {
               const patch = structuredPatch(file, file, original, content, "old", "new", {
                 context: Infinity,
                 context: Infinity,
                 ignoreWhitespace: true,
                 ignoreWhitespace: true,

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

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

+ 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 { makeRunPromise } 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 = makeRunPromise(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 {
 export namespace Identifier {
   const prefixes = {
   const prefixes = {
+    event: "evt",
     session: "ses",
     session: "ses",
     message: "msg",
     message: "msg",
     permission: "per",
     permission: "per",

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

@@ -177,6 +177,12 @@ export namespace LSP {
 
 
   async function getClients(file: string) {
   async function getClients(file: string) {
     const s = await state()
     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 extension = path.parse(file).ext || file
     const result: LSPClient.Info[] = []
     const result: LSPClient.Info[] = []
 
 

+ 2 - 1
packages/opencode/src/plugin/index.ts

@@ -3,7 +3,6 @@ import { Config } from "../config/config"
 import { Bus } from "../bus"
 import { Bus } from "../bus"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { createOpencodeClient } from "@opencode-ai/sdk"
-import { Server } from "../server/server"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
 import { Flag } from "../flag/flag"
 import { Flag } from "../flag/flag"
 import { CodexAuthPlugin } from "./codex"
 import { CodexAuthPlugin } from "./codex"
@@ -58,6 +57,8 @@ export namespace Plugin {
           const hooks: Hooks[] = []
           const hooks: Hooks[] = []
 
 
           yield* Effect.promise(async () => {
           yield* Effect.promise(async () => {
+            const { Server } = await import("../server/server")
+
             const client = createOpencodeClient({
             const client = createOpencodeClient({
               baseUrl: "http://localhost:4096",
               baseUrl: "http://localhost:4096",
               directory: ctx.directory,
               directory: ctx.directory,

+ 147 - 24
packages/opencode/src/project/vcs.ts

@@ -1,17 +1,111 @@
 import { Effect, Layer, ServiceMap } from "effect"
 import { Effect, Layer, ServiceMap } from "effect"
+import path from "path"
 import { Bus } from "@/bus"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRunPromise } from "@/effect/run-service"
 import { makeRunPromise } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
 import { FileWatcher } from "@/file/watcher"
 import { FileWatcher } from "@/file/watcher"
+import { Git } from "@/git"
+import { Snapshot } from "@/snapshot"
 import { Log } from "@/util/log"
 import { Log } from "@/util/log"
-import { git } from "@/util/git"
 import { Instance } from "./instance"
 import { Instance } from "./instance"
 import z from "zod"
 import z from "zod"
 
 
 export namespace Vcs {
 export namespace Vcs {
   const log = Log.create({ service: "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 = {
   export const Event = {
     BranchUpdated: BusEvent.define(
     BranchUpdated: BusEvent.define(
       "vcs.branch.updated",
       "vcs.branch.updated",
@@ -23,7 +117,8 @@ export namespace Vcs {
 
 
   export const Info = z
   export const Info = z
     .object({
     .object({
-      branch: z.string(),
+      branch: z.string().optional(),
+      default_branch: z.string().optional(),
     })
     })
     .meta({
     .meta({
       ref: "VcsInfo",
       ref: "VcsInfo",
@@ -33,37 +128,35 @@ export namespace Vcs {
   export interface Interface {
   export interface Interface {
     readonly init: () => Effect.Effect<void>
     readonly init: () => Effect.Effect<void>
     readonly branch: () => Effect.Effect<string | undefined>
     readonly branch: () => Effect.Effect<string | undefined>
+    readonly defaultBranch: () => Effect.Effect<string | undefined>
+    readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
   }
   }
 
 
   interface State {
   interface State {
     current: string | undefined
     current: string | undefined
+    root: Git.Base | undefined
   }
   }
 
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
   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> = Layer.effect(
     Service,
     Service,
     Effect.gen(function* () {
     Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
       const state = yield* InstanceState.make<State>(
       const state = yield* InstanceState.make<State>(
         Effect.fn("Vcs.state")((ctx) =>
         Effect.fn("Vcs.state")((ctx) =>
           Effect.gen(function* () {
           Effect.gen(function* () {
             if (ctx.project.vcs !== "git") {
             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 value = {
-              current: yield* Effect.promise(() => getCurrentBranch()),
-            }
-            log.info("initialized", { branch: value.current })
+            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 })
 
 
             yield* Effect.acquireRelease(
             yield* Effect.acquireRelease(
               Effect.sync(() =>
               Effect.sync(() =>
@@ -71,12 +164,11 @@ export namespace Vcs {
                   FileWatcher.Event.Updated,
                   FileWatcher.Event.Updated,
                   Instance.bind(async (evt) => {
                   Instance.bind(async (evt) => {
                     if (!evt.properties.file.endsWith("HEAD")) return
                     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 })
-                    }
+                    const next = await get()
+                    if (next === value.current) return
+                    log.info("branch changed", { from: value.current, to: next })
+                    value.current = next
+                    Bus.publish(Event.BranchUpdated, { branch: next })
                   }),
                   }),
                 ),
                 ),
               ),
               ),
@@ -95,11 +187,34 @@ export namespace Vcs {
         branch: Effect.fn("Vcs.branch")(function* () {
         branch: Effect.fn("Vcs.branch")(function* () {
           return yield* InstanceState.use(state, (x) => x.current)
           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))
+
+  const runPromise = makeRunPromise(Service, defaultLayer)
 
 
   export function init() {
   export function init() {
     return runPromise((svc) => svc.init())
     return runPromise((svc) => svc.init())
@@ -108,4 +223,12 @@ export namespace Vcs {
   export function branch() {
   export function branch() {
     return runPromise((svc) => svc.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))
+  }
 }
 }

+ 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 { Bus } from "@/bus"
 import { lazy } from "../../util/lazy"
 import { lazy } from "../../util/lazy"
 import { AsyncQueue } from "../../util/queue"
 import { AsyncQueue } from "../../util/queue"
-import { Instance } from "@/project/instance"
 
 
 const log = Log.create({ service: "server" })
 const log = Log.create({ service: "server" })
 
 
@@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
           )
           )
         }, 10_000)
         }, 10_000)
 
 
-        const unsub = Bus.subscribeAll((event) => {
-          q.push(JSON.stringify(event))
-          if (event.type === Bus.InstanceDisposed.type) {
-            stop()
-          }
-        })
-
         const stop = () => {
         const stop = () => {
           if (done) return
           if (done) return
           done = true
           done = true
@@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
           log.info("event disconnected")
           log.info("event disconnected")
         }
         }
 
 
+        const unsub = Bus.subscribeAll((event) => {
+          q.push(JSON.stringify(event))
+          if (event.type === Bus.InstanceDisposed.type) {
+            stop()
+          }
+        })
+
         stream.onAbort(stop)
         stream.onAbort(stop)
 
 
         try {
         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 { streamSSE } from "hono/streaming"
 import z from "zod"
 import z from "zod"
-import { Bus } from "../../bus"
 import { BusEvent } from "@/bus/bus-event"
 import { BusEvent } from "@/bus/bus-event"
+import { SyncEvent } from "@/sync"
 import { GlobalBus } from "@/bus/global"
 import { GlobalBus } from "@/bus/global"
 import { AsyncQueue } from "@/util/queue"
 import { AsyncQueue } from "@/util/queue"
 import { Instance } from "../../project/instance"
 import { Instance } from "../../project/instance"
@@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
 
 
 export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
 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(() =>
 export const GlobalRoutes = lazy(() =>
   new Hono()
   new Hono()
     .get(
     .get(
@@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
         log.info("global event connected")
         log.info("global event connected")
         c.header("X-Accel-Buffering", "no")
         c.header("X-Accel-Buffering", "no")
         c.header("X-Content-Type-Options", "nosniff")
         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(
             q.push(
               JSON.stringify({
               JSON.stringify({
                 payload: {
                 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 sessionID = c.req.valid("param").sessionID
         const updates = c.req.valid("json")
         const updates = c.req.valid("json")
 
 
-        let session = await Session.get(sessionID)
         if (updates.title !== undefined) {
         if (updates.title !== undefined) {
-          session = await Session.setTitle({ sessionID, title: updates.title })
+          await Session.setTitle({ sessionID, title: updates.title })
         }
         }
         if (updates.time?.archived !== undefined) {
         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)
         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 { Log } from "../util/log"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { Hono } from "hono"
 import { Hono } from "hono"
@@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
 import { HTTPException } from "hono/http-exception"
 import { HTTPException } from "hono/http-exception"
 import { errors } from "./error"
 import { errors } from "./error"
 import { Filesystem } from "@/util/filesystem"
 import { Filesystem } from "@/util/filesystem"
+import { Snapshot } from "@/snapshot"
 import { QuestionRoutes } from "./routes/question"
 import { QuestionRoutes } from "./routes/question"
 import { PermissionRoutes } from "./routes/permission"
 import { PermissionRoutes } from "./routes/permission"
 import { GlobalRoutes } from "./routes/global"
 import { GlobalRoutes } from "./routes/global"
 import { MDNS } from "./mdns"
 import { MDNS } from "./mdns"
 import { lazy } from "@/util/lazy"
 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
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
 globalThis.AI_SDK_LOG_WARNINGS = false
 
 
+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 {
 export namespace Server {
   const log = Log.create({ service: "server" })
   const log = Log.create({ service: "server" })
 
 
@@ -330,12 +338,40 @@ export namespace Server {
           },
           },
         }),
         }),
         async (c) => {
         async (c) => {
-          const branch = await Vcs.branch()
+          const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
           return c.json({
           return c.json({
             branch,
             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(
       .get(
         "/command",
         "/command",
         describeRoute({
         describeRoute({
@@ -506,10 +542,13 @@ export namespace Server {
             host: "app.opencode.ai",
             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
         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 { Agent } from "@/agent/agent"
 import { Plugin } from "@/plugin"
 import { Plugin } from "@/plugin"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
+import { NotFoundError } from "@/storage/db"
 import { ProviderTransform } from "@/provider/transform"
 import { ProviderTransform } from "@/provider/transform"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { ModelID, ProviderID } from "@/provider/schema"
 
 
@@ -60,7 +61,11 @@ export namespace SessionCompaction {
     const config = await Config.get()
     const config = await Config.get()
     if (config.compaction?.prune === false) return
     if (config.compaction?.prune === false) return
     log.info("pruning")
     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 total = 0
     let pruned = 0
     let pruned = 0
     const toPrune = []
     const toPrune = []

+ 98 - 199
packages/opencode/src/session/index.ts

@@ -9,12 +9,14 @@ import { Config } from "../config/config"
 import { Flag } from "../flag/flag"
 import { Flag } from "../flag/flag"
 import { Installation } from "../installation"
 import { Installation } from "../installation"
 
 
-import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
+import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
+import { SyncEvent } from "../sync"
 import type { SQL } from "../storage/db"
 import type { SQL } from "../storage/db"
-import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { SessionTable } from "./session.sql"
 import { ProjectTable } from "../project/project.sql"
 import { ProjectTable } from "../project/project.sql"
 import { Storage } from "@/storage/storage"
 import { Storage } from "@/storage/storage"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
+import { updateSchema } from "../util/update-schema"
 import { MessageV2 } from "./message-v2"
 import { MessageV2 } from "./message-v2"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
 import { SessionPrompt } from "./prompt"
 import { SessionPrompt } from "./prompt"
@@ -182,24 +184,40 @@ export namespace Session {
   export type GlobalInfo = z.output<typeof GlobalInfo>
   export type GlobalInfo = z.output<typeof GlobalInfo>
 
 
   export const Event = {
   export const Event = {
-    Created: BusEvent.define(
-      "session.created",
-      z.object({
+    Created: SyncEvent.define({
+      type: "session.created",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
+        sessionID: SessionID.zod,
         info: Info,
         info: Info,
       }),
       }),
-    ),
-    Updated: BusEvent.define(
-      "session.updated",
-      z.object({
+    }),
+    Updated: SyncEvent.define({
+      type: "session.updated",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
+        sessionID: SessionID.zod,
+        info: updateSchema(Info).extend({
+          share: updateSchema(Info.shape.share.unwrap()).optional(),
+          time: updateSchema(Info.shape.time).optional(),
+        }),
+      }),
+      busSchema: z.object({
+        sessionID: SessionID.zod,
         info: Info,
         info: Info,
       }),
       }),
-    ),
-    Deleted: BusEvent.define(
-      "session.deleted",
-      z.object({
+    }),
+    Deleted: SyncEvent.define({
+      type: "session.deleted",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
+        sessionID: SessionID.zod,
         info: Info,
         info: Info,
       }),
       }),
-    ),
+    }),
     Diff: BusEvent.define(
     Diff: BusEvent.define(
       "session.diff",
       "session.diff",
       z.object({
       z.object({
@@ -280,18 +298,8 @@ export namespace Session {
   )
   )
 
 
   export const touch = fn(SessionID.zod, async (sessionID) => {
   export const touch = fn(SessionID.zod, async (sessionID) => {
-    const now = Date.now()
-    Database.use((db) => {
-      const row = db
-        .update(SessionTable)
-        .set({ time_updated: now })
-        .where(eq(SessionTable.id, sessionID))
-        .returning()
-        .get()
-      if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
-      const info = fromRow(row)
-      Database.effect(() => Bus.publish(Event.Updated, { info }))
-    })
+    const time = Date.now()
+    SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
   })
   })
 
 
   export async function createNext(input: {
   export async function createNext(input: {
@@ -318,22 +326,25 @@ export namespace Session {
       },
       },
     }
     }
     log.info("created", result)
     log.info("created", result)
-    Database.use((db) => {
-      db.insert(SessionTable).values(toRow(result)).run()
-      Database.effect(() =>
-        Bus.publish(Event.Created, {
-          info: result,
-        }),
-      )
-    })
+
+    SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
+
     const cfg = await Config.get()
     const cfg = await Config.get()
-    if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
+    if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
       share(result.id).catch(() => {
       share(result.id).catch(() => {
         // Silently ignore sharing errors during session creation
         // Silently ignore sharing errors during session creation
       })
       })
-    Bus.publish(Event.Updated, {
-      info: result,
-    })
+    }
+
+    if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+      // This only exist for backwards compatibility. We should not be
+      // manually publishing this event; it is a sync event now
+      Bus.publish(Event.Updated, {
+        sessionID: result.id,
+        info: result,
+      })
+    }
+
     return result
     return result
   }
   }
 
 
@@ -357,12 +368,9 @@ export namespace Session {
     }
     }
     const { ShareNext } = await import("@/share/share-next")
     const { ShareNext } = await import("@/share/share-next")
     const share = await ShareNext.create(id)
     const share = await ShareNext.create(id)
-    Database.use((db) => {
-      const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
-      if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
-      const info = fromRow(row)
-      Database.effect(() => Bus.publish(Event.Updated, { info }))
-    })
+
+    SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
+
     return share
     return share
   })
   })
 
 
@@ -370,12 +378,8 @@ export namespace Session {
     // Use ShareNext to remove the share (same as share function uses ShareNext to create)
     // Use ShareNext to remove the share (same as share function uses ShareNext to create)
     const { ShareNext } = await import("@/share/share-next")
     const { ShareNext } = await import("@/share/share-next")
     await ShareNext.remove(id)
     await ShareNext.remove(id)
-    Database.use((db) => {
-      const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
-      if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
-      const info = fromRow(row)
-      Database.effect(() => Bus.publish(Event.Updated, { info }))
-    })
+
+    SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
   })
   })
 
 
   export const setTitle = fn(
   export const setTitle = fn(
@@ -384,18 +388,7 @@ export namespace Session {
       title: z.string(),
       title: z.string(),
     }),
     }),
     async (input) => {
     async (input) => {
-      return Database.use((db) => {
-        const row = db
-          .update(SessionTable)
-          .set({ title: input.title })
-          .where(eq(SessionTable.id, input.sessionID))
-          .returning()
-          .get()
-        if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
-        const info = fromRow(row)
-        Database.effect(() => Bus.publish(Event.Updated, { info }))
-        return info
-      })
+      SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
     },
     },
   )
   )
 
 
@@ -405,18 +398,7 @@ export namespace Session {
       time: z.number().optional(),
       time: z.number().optional(),
     }),
     }),
     async (input) => {
     async (input) => {
-      return Database.use((db) => {
-        const row = db
-          .update(SessionTable)
-          .set({ time_archived: input.time })
-          .where(eq(SessionTable.id, input.sessionID))
-          .returning()
-          .get()
-        if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
-        const info = fromRow(row)
-        Database.effect(() => Bus.publish(Event.Updated, { info }))
-        return info
-      })
+      SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
     },
     },
   )
   )
 
 
@@ -426,17 +408,9 @@ export namespace Session {
       permission: Permission.Ruleset,
       permission: Permission.Ruleset,
     }),
     }),
     async (input) => {
     async (input) => {
-      return Database.use((db) => {
-        const row = db
-          .update(SessionTable)
-          .set({ permission: input.permission, time_updated: Date.now() })
-          .where(eq(SessionTable.id, input.sessionID))
-          .returning()
-          .get()
-        if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
-        const info = fromRow(row)
-        Database.effect(() => Bus.publish(Event.Updated, { info }))
-        return info
+      SyncEvent.run(Event.Updated, {
+        sessionID: input.sessionID,
+        info: { permission: input.permission, time: { updated: Date.now() } },
       })
       })
     },
     },
   )
   )
@@ -448,42 +422,24 @@ export namespace Session {
       summary: Info.shape.summary,
       summary: Info.shape.summary,
     }),
     }),
     async (input) => {
     async (input) => {
-      return Database.use((db) => {
-        const row = db
-          .update(SessionTable)
-          .set({
-            revert: input.revert ?? null,
-            summary_additions: input.summary?.additions,
-            summary_deletions: input.summary?.deletions,
-            summary_files: input.summary?.files,
-            time_updated: Date.now(),
-          })
-          .where(eq(SessionTable.id, input.sessionID))
-          .returning()
-          .get()
-        if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
-        const info = fromRow(row)
-        Database.effect(() => Bus.publish(Event.Updated, { info }))
-        return info
+      SyncEvent.run(Event.Updated, {
+        sessionID: input.sessionID,
+        info: {
+          summary: input.summary,
+          time: { updated: Date.now() },
+          revert: input.revert,
+        },
       })
       })
     },
     },
   )
   )
 
 
   export const clearRevert = fn(SessionID.zod, async (sessionID) => {
   export const clearRevert = fn(SessionID.zod, async (sessionID) => {
-    return Database.use((db) => {
-      const row = db
-        .update(SessionTable)
-        .set({
-          revert: null,
-          time_updated: Date.now(),
-        })
-        .where(eq(SessionTable.id, sessionID))
-        .returning()
-        .get()
-      if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
-      const info = fromRow(row)
-      Database.effect(() => Bus.publish(Event.Updated, { info }))
-      return info
+    SyncEvent.run(Event.Updated, {
+      sessionID,
+      info: {
+        time: { updated: Date.now() },
+        revert: null,
+      },
     })
     })
   })
   })
 
 
@@ -493,22 +449,12 @@ export namespace Session {
       summary: Info.shape.summary,
       summary: Info.shape.summary,
     }),
     }),
     async (input) => {
     async (input) => {
-      return Database.use((db) => {
-        const row = db
-          .update(SessionTable)
-          .set({
-            summary_additions: input.summary?.additions,
-            summary_deletions: input.summary?.deletions,
-            summary_files: input.summary?.files,
-            time_updated: Date.now(),
-          })
-          .where(eq(SessionTable.id, input.sessionID))
-          .returning()
-          .get()
-        if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
-        const info = fromRow(row)
-        Database.effect(() => Bus.publish(Event.Updated, { info }))
-        return info
+      SyncEvent.run(Event.Updated, {
+        sessionID: input.sessionID,
+        info: {
+          time: { updated: Date.now() },
+          summary: input.summary,
+        },
       })
       })
     },
     },
   )
   )
@@ -662,46 +608,28 @@ export namespace Session {
   })
   })
 
 
   export const remove = fn(SessionID.zod, async (sessionID) => {
   export const remove = fn(SessionID.zod, async (sessionID) => {
-    const project = Instance.project
     try {
     try {
       const session = await get(sessionID)
       const session = await get(sessionID)
       for (const child of await children(sessionID)) {
       for (const child of await children(sessionID)) {
         await remove(child.id)
         await remove(child.id)
       }
       }
       await unshare(sessionID).catch(() => {})
       await unshare(sessionID).catch(() => {})
-      // CASCADE delete handles messages and parts automatically
-      Database.use((db) => {
-        db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
-        Database.effect(() =>
-          Bus.publish(Event.Deleted, {
-            info: session,
-          }),
-        )
-      })
+
+      SyncEvent.run(Event.Deleted, { sessionID, info: session })
+
+      // Eagerly remove event sourcing data to free up space
+      SyncEvent.remove(sessionID)
     } catch (e) {
     } catch (e) {
       log.error(e)
       log.error(e)
     }
     }
   })
   })
 
 
   export const updateMessage = fn(MessageV2.Info, async (msg) => {
   export const updateMessage = fn(MessageV2.Info, async (msg) => {
-    const time_created = msg.time.created
-    const { id, sessionID, ...data } = msg
-    Database.use((db) => {
-      db.insert(MessageTable)
-        .values({
-          id,
-          session_id: sessionID,
-          time_created,
-          data,
-        })
-        .onConflictDoUpdate({ target: MessageTable.id, set: { data } })
-        .run()
-      Database.effect(() =>
-        Bus.publish(MessageV2.Event.Updated, {
-          info: msg,
-        }),
-      )
+    SyncEvent.run(MessageV2.Event.Updated, {
+      sessionID: msg.sessionID,
+      info: msg,
     })
     })
+
     return msg
     return msg
   })
   })
 
 
@@ -711,17 +639,9 @@ export namespace Session {
       messageID: MessageID.zod,
       messageID: MessageID.zod,
     }),
     }),
     async (input) => {
     async (input) => {
-      // CASCADE delete handles parts automatically
-      Database.use((db) => {
-        db.delete(MessageTable)
-          .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
-          .run()
-        Database.effect(() =>
-          Bus.publish(MessageV2.Event.Removed, {
-            sessionID: input.sessionID,
-            messageID: input.messageID,
-          }),
-        )
+      SyncEvent.run(MessageV2.Event.Removed, {
+        sessionID: input.sessionID,
+        messageID: input.messageID,
       })
       })
       return input.messageID
       return input.messageID
     },
     },
@@ -734,17 +654,10 @@ export namespace Session {
       partID: PartID.zod,
       partID: PartID.zod,
     }),
     }),
     async (input) => {
     async (input) => {
-      Database.use((db) => {
-        db.delete(PartTable)
-          .where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
-          .run()
-        Database.effect(() =>
-          Bus.publish(MessageV2.Event.PartRemoved, {
-            sessionID: input.sessionID,
-            messageID: input.messageID,
-            partID: input.partID,
-          }),
-        )
+      SyncEvent.run(MessageV2.Event.PartRemoved, {
+        sessionID: input.sessionID,
+        messageID: input.messageID,
+        partID: input.partID,
       })
       })
       return input.partID
       return input.partID
     },
     },
@@ -753,24 +666,10 @@ export namespace Session {
   const UpdatePartInput = MessageV2.Part
   const UpdatePartInput = MessageV2.Part
 
 
   export const updatePart = fn(UpdatePartInput, async (part) => {
   export const updatePart = fn(UpdatePartInput, async (part) => {
-    const { id, messageID, sessionID, ...data } = part
-    const time = Date.now()
-    Database.use((db) => {
-      db.insert(PartTable)
-        .values({
-          id,
-          message_id: messageID,
-          session_id: sessionID,
-          time_created: time,
-          data,
-        })
-        .onConflictDoUpdate({ target: PartTable.id, set: { data } })
-        .run()
-      Database.effect(() =>
-        Bus.publish(MessageV2.Event.PartUpdated, {
-          part: structuredClone(part),
-        }),
-      )
+    SyncEvent.run(MessageV2.Event.PartUpdated, {
+      sessionID: part.sessionID,
+      part: structuredClone(part),
+      time: Date.now(),
     })
     })
     return part
     return part
   })
   })

+ 54 - 20
packages/opencode/src/session/message-v2.ts

@@ -6,17 +6,22 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
 import { LSP } from "../lsp"
 import { LSP } from "../lsp"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
 import { fn } from "@/util/fn"
 import { fn } from "@/util/fn"
+import { SyncEvent } from "../sync"
 import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
 import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
 import { MessageTable, PartTable, SessionTable } from "./session.sql"
 import { MessageTable, PartTable, SessionTable } from "./session.sql"
-import { ProviderTransform } from "@/provider/transform"
-import { STATUS_CODES } from "http"
-import { Storage } from "@/storage/storage"
 import { ProviderError } from "@/provider/error"
 import { ProviderError } from "@/provider/error"
 import { iife } from "@/util/iife"
 import { iife } from "@/util/iife"
 import type { SystemError } from "bun"
 import type { SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { ModelID, ProviderID } from "@/provider/schema"
 
 
+/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
+interface FetchDecompressionError extends Error {
+  code: "ZlibError"
+  errno: number
+  path: string
+}
+
 export namespace MessageV2 {
 export namespace MessageV2 {
   export function isMedia(mime: string) {
   export function isMedia(mime: string) {
     return mime.startsWith("image/") || mime === "application/pdf"
     return mime.startsWith("image/") || mime === "application/pdf"
@@ -449,25 +454,34 @@ export namespace MessageV2 {
   export type Info = z.infer<typeof Info>
   export type Info = z.infer<typeof Info>
 
 
   export const Event = {
   export const Event = {
-    Updated: BusEvent.define(
-      "message.updated",
-      z.object({
+    Updated: SyncEvent.define({
+      type: "message.updated",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
+        sessionID: SessionID.zod,
         info: Info,
         info: Info,
       }),
       }),
-    ),
-    Removed: BusEvent.define(
-      "message.removed",
-      z.object({
+    }),
+    Removed: SyncEvent.define({
+      type: "message.removed",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
         sessionID: SessionID.zod,
         sessionID: SessionID.zod,
         messageID: MessageID.zod,
         messageID: MessageID.zod,
       }),
       }),
-    ),
-    PartUpdated: BusEvent.define(
-      "message.part.updated",
-      z.object({
+    }),
+    PartUpdated: SyncEvent.define({
+      type: "message.part.updated",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
+        sessionID: SessionID.zod,
         part: Part,
         part: Part,
+        time: z.number(),
       }),
       }),
-    ),
+    }),
     PartDelta: BusEvent.define(
     PartDelta: BusEvent.define(
       "message.part.delta",
       "message.part.delta",
       z.object({
       z.object({
@@ -478,14 +492,16 @@ export namespace MessageV2 {
         delta: z.string(),
         delta: z.string(),
       }),
       }),
     ),
     ),
-    PartRemoved: BusEvent.define(
-      "message.part.removed",
-      z.object({
+    PartRemoved: SyncEvent.define({
+      type: "message.part.removed",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
         sessionID: SessionID.zod,
         sessionID: SessionID.zod,
         messageID: MessageID.zod,
         messageID: MessageID.zod,
         partID: PartID.zod,
         partID: PartID.zod,
       }),
       }),
-    ),
+    }),
   }
   }
 
 
   export const WithParts = z.object({
   export const WithParts = z.object({
@@ -897,7 +913,10 @@ export namespace MessageV2 {
     return result
     return result
   }
   }
 
 
-  export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
+  export function fromError(
+    e: unknown,
+    ctx: { providerID: ProviderID; aborted?: boolean },
+  ): NonNullable<Assistant["error"]> {
     switch (true) {
     switch (true) {
       case e instanceof DOMException && e.name === "AbortError":
       case e instanceof DOMException && e.name === "AbortError":
         return new MessageV2.AbortedError(
         return new MessageV2.AbortedError(
@@ -929,6 +948,21 @@ export namespace MessageV2 {
           },
           },
           { cause: e },
           { cause: e },
         ).toObject()
         ).toObject()
+      case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
+        if (ctx.aborted) {
+          return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
+        }
+        return new MessageV2.APIError(
+          {
+            message: "Response decompression failed",
+            isRetryable: true,
+            metadata: {
+              code: (e as FetchDecompressionError).code,
+              message: e.message,
+            },
+          },
+          { cause: e },
+        ).toObject()
       case APICallError.isInstance(e):
       case APICallError.isInstance(e):
         const parsed = ProviderError.parseAPICallError({
         const parsed = ProviderError.parseAPICallError({
           providerID: ctx.providerID,
           providerID: ctx.providerID,

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

@@ -356,7 +356,7 @@ export namespace SessionProcessor {
               error: e,
               error: e,
               stack: JSON.stringify(e.stack),
               stack: JSON.stringify(e.stack),
             })
             })
-            const error = MessageV2.fromError(e, { providerID: input.model.providerID })
+            const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
             if (MessageV2.ContextOverflowError.isInstance(error)) {
             if (MessageV2.ContextOverflowError.isInstance(error)) {
               needsCompaction = true
               needsCompaction = true
               Bus.publish(Session.Event.Error, {
               Bus.publish(Session.Event.Error, {

+ 135 - 0
packages/opencode/src/session/projectors.ts

@@ -0,0 +1,135 @@
+import { NotFoundError, eq, and } from "../storage/db"
+import { SyncEvent } from "@/sync"
+import { Session } from "./index"
+import { MessageV2 } from "./message-v2"
+import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { ProjectTable } from "../project/project.sql"
+import { Log } from "../util/log"
+
+const log = Log.create({ service: "session.projector" })
+
+function foreign(err: unknown) {
+  if (typeof err !== "object" || err === null) return false
+  if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
+  return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
+}
+
+export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
+
+function grab<T extends object, K1 extends keyof T, X>(
+  obj: T,
+  field1: K1,
+  cb?: (val: NonNullable<T[K1]>) => X,
+): X | undefined {
+  if (obj == undefined || !(field1 in obj)) return undefined
+
+  const val = obj[field1]
+  if (val && typeof val === "object" && cb) {
+    return cb(val)
+  }
+  if (val === undefined) {
+    throw new Error(
+      "Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
+    )
+  }
+  return val as X | undefined
+}
+
+export function toPartialRow(info: DeepPartial<Session.Info>) {
+  const obj = {
+    id: grab(info, "id"),
+    project_id: grab(info, "projectID"),
+    workspace_id: grab(info, "workspaceID"),
+    parent_id: grab(info, "parentID"),
+    slug: grab(info, "slug"),
+    directory: grab(info, "directory"),
+    title: grab(info, "title"),
+    version: grab(info, "version"),
+    share_url: grab(info, "share", (v) => grab(v, "url")),
+    summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
+    summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
+    summary_files: grab(info, "summary", (v) => grab(v, "files")),
+    summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
+    revert: grab(info, "revert"),
+    permission: grab(info, "permission"),
+    time_created: grab(info, "time", (v) => grab(v, "created")),
+    time_updated: grab(info, "time", (v) => grab(v, "updated")),
+    time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
+    time_archived: grab(info, "time", (v) => grab(v, "archived")),
+  }
+
+  return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
+}
+
+export default [
+  SyncEvent.project(Session.Event.Created, (db, data) => {
+    db.insert(SessionTable).values(Session.toRow(data.info)).run()
+  }),
+
+  SyncEvent.project(Session.Event.Updated, (db, data) => {
+    const info = data.info
+    const row = db
+      .update(SessionTable)
+      .set(toPartialRow(info))
+      .where(eq(SessionTable.id, data.sessionID))
+      .returning()
+      .get()
+    if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
+  }),
+
+  SyncEvent.project(Session.Event.Deleted, (db, data) => {
+    db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
+  }),
+
+  SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
+    const time_created = data.info.time.created
+    const { id, sessionID, ...rest } = data.info
+
+    try {
+      db.insert(MessageTable)
+        .values({
+          id,
+          session_id: sessionID,
+          time_created,
+          data: rest,
+        })
+        .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
+        .run()
+    } catch (err) {
+      if (!foreign(err)) throw err
+      log.warn("ignored late message update", { messageID: id, sessionID })
+    }
+  }),
+
+  SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
+    db.delete(MessageTable)
+      .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
+      .run()
+  }),
+
+  SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
+    db.delete(PartTable)
+      .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
+      .run()
+  }),
+
+  SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
+    const { id, messageID, sessionID, ...rest } = data.part
+
+    try {
+      db.insert(PartTable)
+        .values({
+          id,
+          message_id: messageID,
+          session_id: sessionID,
+          time_created: data.time,
+          data: rest,
+        })
+        .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
+        .run()
+    } catch (err) {
+      if (!foreign(err)) throw err
+      log.warn("ignored late part update", { partID: id, messageID, sessionID })
+    }
+  }),
+]

+ 6 - 6
packages/opencode/src/session/revert.ts

@@ -4,8 +4,7 @@ import { Snapshot } from "../snapshot"
 import { MessageV2 } from "./message-v2"
 import { MessageV2 } from "./message-v2"
 import { Session } from "."
 import { Session } from "."
 import { Log } from "../util/log"
 import { Log } from "../util/log"
-import { Database, eq } from "../storage/db"
-import { MessageTable, PartTable } from "./session.sql"
+import { SyncEvent } from "../sync"
 import { Storage } from "@/storage/storage"
 import { Storage } from "@/storage/storage"
 import { Bus } from "../bus"
 import { Bus } from "../bus"
 import { SessionPrompt } from "./prompt"
 import { SessionPrompt } from "./prompt"
@@ -113,8 +112,10 @@ export namespace SessionRevert {
       remove.push(msg)
       remove.push(msg)
     }
     }
     for (const msg of remove) {
     for (const msg of remove) {
-      Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
-      await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
+      SyncEvent.run(MessageV2.Event.Removed, {
+        sessionID: sessionID,
+        messageID: msg.info.id,
+      })
     }
     }
     if (session.revert.partID && target) {
     if (session.revert.partID && target) {
       const partID = session.revert.partID
       const partID = session.revert.partID
@@ -124,8 +125,7 @@ export namespace SessionRevert {
         const removeParts = target.parts.slice(removeStart)
         const removeParts = target.parts.slice(removeStart)
         target.parts = preserveParts
         target.parts = preserveParts
         for (const part of removeParts) {
         for (const part of removeParts) {
-          Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
-          await Bus.publish(MessageV2.Event.PartRemoved, {
+          SyncEvent.run(MessageV2.Event.PartRemoved, {
             sessionID: sessionID,
             sessionID: sessionID,
             messageID: target.info.id,
             messageID: target.info.id,
             partID: part.id,
             partID: part.id,

+ 9 - 10
packages/opencode/src/share/share-next.ts

@@ -66,29 +66,28 @@ export namespace ShareNext {
   export async function init() {
   export async function init() {
     if (disabled) return
     if (disabled) return
     Bus.subscribe(Session.Event.Updated, async (evt) => {
     Bus.subscribe(Session.Event.Updated, async (evt) => {
-      await sync(evt.properties.info.id, [
+      const session = await Session.get(evt.properties.sessionID)
+
+      await sync(session.id, [
         {
         {
           type: "session",
           type: "session",
-          data: evt.properties.info,
+          data: session,
         },
         },
       ])
       ])
     })
     })
     Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
     Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
-      await sync(evt.properties.info.sessionID, [
+      const info = evt.properties.info
+      await sync(info.sessionID, [
         {
         {
           type: "message",
           type: "message",
           data: evt.properties.info,
           data: evt.properties.info,
         },
         },
       ])
       ])
-      if (evt.properties.info.role === "user") {
-        await sync(evt.properties.info.sessionID, [
+      if (info.role === "user") {
+        await sync(info.sessionID, [
           {
           {
             type: "model",
             type: "model",
-            data: [
-              await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
-                (m) => m,
-              ),
-            ],
+            data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
           },
           },
         ])
         ])
       }
       }

+ 250 - 152
packages/opencode/src/snapshot/index.ts

@@ -1,5 +1,5 @@
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
 import path from "path"
 import z from "zod"
 import z from "zod"
@@ -7,6 +7,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRunPromise } from "@/effect/run-service"
 import { makeRunPromise } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { AppFileSystem } from "@/filesystem"
+import { Hash } from "@/util/hash"
 import { Config } from "../config/config"
 import { Config } from "../config/config"
 import { Global } from "../global"
 import { Global } from "../global"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
@@ -34,10 +35,10 @@ export namespace Snapshot {
 
 
   const log = Log.create({ service: "snapshot" })
   const log = Log.create({ service: "snapshot" })
   const prune = "7.days"
   const prune = "7.days"
+  const limit = 2 * 1024 * 1024
   const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
   const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
   const cfg = ["-c", "core.autocrlf=false", ...core]
   const cfg = ["-c", "core.autocrlf=false", ...core]
   const quote = [...cfg, "-c", "core.quotepath=false"]
   const quote = [...cfg, "-c", "core.quotepath=false"]
-
   interface GitResult {
   interface GitResult {
     readonly code: ChildProcessSpawner.ExitCode
     readonly code: ChildProcessSpawner.ExitCode
     readonly text: string
     readonly text: string
@@ -65,12 +66,23 @@ export namespace Snapshot {
       Effect.gen(function* () {
       Effect.gen(function* () {
         const fs = yield* AppFileSystem.Service
         const fs = yield* AppFileSystem.Service
         const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
         const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+        const locks = new Map<string, Semaphore.Semaphore>()
+
+        const lock = (key: string) => {
+          const hit = locks.get(key)
+          if (hit) return hit
+
+          const next = Semaphore.makeUnsafe(1)
+          locks.set(key, next)
+          return next
+        }
+
         const state = yield* InstanceState.make<State>(
         const state = yield* InstanceState.make<State>(
           Effect.fn("Snapshot.state")(function* (ctx) {
           Effect.fn("Snapshot.state")(function* (ctx) {
             const state = {
             const state = {
               directory: ctx.directory,
               directory: ctx.directory,
               worktree: ctx.worktree,
               worktree: ctx.worktree,
-              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
+              gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
               vcs: ctx.project.vcs,
               vcs: ctx.project.vcs,
             }
             }
 
 
@@ -107,6 +119,7 @@ export namespace Snapshot {
             const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
             const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
             const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
             const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
             const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
             const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+            const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
 
 
             const enabled = Effect.fnUntraced(function* () {
             const enabled = Effect.fnUntraced(function* () {
               if (state.vcs !== "git") return false
               if (state.vcs !== "git") return false
@@ -123,192 +136,277 @@ export namespace Snapshot {
               return file
               return file
             })
             })
 
 
-            const sync = Effect.fnUntraced(function* () {
+            const sync = Effect.fnUntraced(function* (list: string[] = []) {
               const file = yield* excludes()
               const file = yield* excludes()
               const target = path.join(state.gitdir, "info", "exclude")
               const target = path.join(state.gitdir, "info", "exclude")
+              const text = [
+                file ? (yield* read(file)).trimEnd() : "",
+                ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
+              ]
+                .filter(Boolean)
+                .join("\n")
               yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
               yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
-              if (!file) {
-                yield* fs.writeFileString(target, "").pipe(Effect.orDie)
-                return
-              }
-              yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+              yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
             })
             })
 
 
             const add = Effect.fnUntraced(function* () {
             const add = Effect.fnUntraced(function* () {
               yield* sync()
               yield* sync()
-              yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
-            })
+              const [diff, other] = yield* Effect.all(
+                [
+                  git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
+                    cwd: state.directory,
+                  }),
+                  git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
+                    cwd: state.directory,
+                  }),
+                ],
+                { concurrency: 2 },
+              )
+              if (diff.code !== 0 || other.code !== 0) {
+                log.warn("failed to list snapshot files", {
+                  diffCode: diff.code,
+                  diffStderr: diff.stderr,
+                  otherCode: other.code,
+                  otherStderr: other.stderr,
+                })
+                return
+              }
 
 
-            const cleanup = Effect.fnUntraced(function* () {
-              if (!(yield* enabled())) return
-              if (!(yield* exists(state.gitdir))) return
-              const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+              const tracked = diff.text.split("\0").filter(Boolean)
+              const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
+              if (!all.length) return
+
+              const large = (yield* Effect.all(
+                all.map((item) =>
+                  fs
+                    .stat(path.join(state.directory, item))
+                    .pipe(Effect.catch(() => Effect.void))
+                    .pipe(
+                      Effect.map((stat) => {
+                        if (!stat || stat.type !== "File") return
+                        const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
+                        return size > limit ? item : undefined
+                      }),
+                    ),
+                ),
+                { concurrency: 8 },
+              )).filter((item): item is string => Boolean(item))
+              yield* sync(large)
+              const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
               if (result.code !== 0) {
               if (result.code !== 0) {
-                log.warn("cleanup failed", {
+                log.warn("failed to add snapshot files", {
                   exitCode: result.code,
                   exitCode: result.code,
                   stderr: result.stderr,
                   stderr: result.stderr,
                 })
                 })
-                return
               }
               }
-              log.info("cleanup", { prune })
+            })
+
+            const cleanup = Effect.fnUntraced(function* () {
+              return yield* locked(
+                Effect.gen(function* () {
+                  if (!(yield* enabled())) return
+                  if (!(yield* exists(state.gitdir))) return
+                  const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+                  if (result.code !== 0) {
+                    log.warn("cleanup failed", {
+                      exitCode: result.code,
+                      stderr: result.stderr,
+                    })
+                    return
+                  }
+                  log.info("cleanup", { prune })
+                }),
+              )
             })
             })
 
 
             const track = Effect.fnUntraced(function* () {
             const track = Effect.fnUntraced(function* () {
-              if (!(yield* enabled())) return
-              const existed = yield* exists(state.gitdir)
-              yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
-              if (!existed) {
-                yield* git(["init"], {
-                  env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
-                })
-                yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
-                yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
-                log.info("initialized")
-              }
-              yield* add()
-              const result = yield* git(args(["write-tree"]), { cwd: state.directory })
-              const hash = result.text.trim()
-              log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
-              return hash
+              return yield* locked(
+                Effect.gen(function* () {
+                  if (!(yield* enabled())) return
+                  const existed = yield* exists(state.gitdir)
+                  yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+                  if (!existed) {
+                    yield* git(["init"], {
+                      env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+                    })
+                    yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+                    yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+                    log.info("initialized")
+                  }
+                  yield* add()
+                  const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+                  const hash = result.text.trim()
+                  log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+                  return hash
+                }),
+              )
             })
             })
 
 
             const patch = Effect.fnUntraced(function* (hash: string) {
             const patch = Effect.fnUntraced(function* (hash: string) {
-              yield* add()
-              const result = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
-                {
-                  cwd: state.directory,
-                },
+              return yield* locked(
+                Effect.gen(function* () {
+                  yield* add()
+                  const result = yield* git(
+                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                    {
+                      cwd: state.directory,
+                    },
+                  )
+                  if (result.code !== 0) {
+                    log.warn("failed to get diff", { hash, exitCode: result.code })
+                    return { hash, files: [] }
+                  }
+                  return {
+                    hash,
+                    files: result.text
+                      .trim()
+                      .split("\n")
+                      .map((x) => x.trim())
+                      .filter(Boolean)
+                      .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+                  }
+                }),
               )
               )
-              if (result.code !== 0) {
-                log.warn("failed to get diff", { hash, exitCode: result.code })
-                return { hash, files: [] }
-              }
-              return {
-                hash,
-                files: result.text
-                  .trim()
-                  .split("\n")
-                  .map((x) => x.trim())
-                  .filter(Boolean)
-                  .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
-              }
             })
             })
 
 
             const restore = Effect.fnUntraced(function* (snapshot: string) {
             const restore = Effect.fnUntraced(function* (snapshot: string) {
-              log.info("restore", { commit: snapshot })
-              const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
-              if (result.code === 0) {
-                const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
-                if (checkout.code === 0) return
-                log.error("failed to restore snapshot", {
-                  snapshot,
-                  exitCode: checkout.code,
-                  stderr: checkout.stderr,
-                })
-                return
-              }
-              log.error("failed to restore snapshot", {
-                snapshot,
-                exitCode: result.code,
-                stderr: result.stderr,
-              })
+              return yield* locked(
+                Effect.gen(function* () {
+                  log.info("restore", { commit: snapshot })
+                  const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+                  if (result.code === 0) {
+                    const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
+                      cwd: state.worktree,
+                    })
+                    if (checkout.code === 0) return
+                    log.error("failed to restore snapshot", {
+                      snapshot,
+                      exitCode: checkout.code,
+                      stderr: checkout.stderr,
+                    })
+                    return
+                  }
+                  log.error("failed to restore snapshot", {
+                    snapshot,
+                    exitCode: result.code,
+                    stderr: result.stderr,
+                  })
+                }),
+              )
             })
             })
 
 
             const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
             const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
-              const seen = new Set<string>()
-              for (const item of patches) {
-                for (const file of item.files) {
-                  if (seen.has(file)) continue
-                  seen.add(file)
-                  log.info("reverting", { file, hash: item.hash })
-                  const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
-                    cwd: state.worktree,
-                  })
-                  if (result.code !== 0) {
-                    const rel = path.relative(state.worktree, file)
-                    const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
-                      cwd: state.worktree,
-                    })
-                    if (tree.code === 0 && tree.text.trim()) {
-                      log.info("file existed in snapshot but checkout failed, keeping", { file })
-                    } else {
-                      log.info("file did not exist in snapshot, deleting", { file })
-                      yield* remove(file)
+              return yield* locked(
+                Effect.gen(function* () {
+                  const seen = new Set<string>()
+                  for (const item of patches) {
+                    for (const file of item.files) {
+                      if (seen.has(file)) continue
+                      seen.add(file)
+                      log.info("reverting", { file, hash: item.hash })
+                      const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
+                        cwd: state.worktree,
+                      })
+                      if (result.code !== 0) {
+                        const rel = path.relative(state.worktree, file)
+                        const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
+                          cwd: state.worktree,
+                        })
+                        if (tree.code === 0 && tree.text.trim()) {
+                          log.info("file existed in snapshot but checkout failed, keeping", { file })
+                        } else {
+                          log.info("file did not exist in snapshot, deleting", { file })
+                          yield* remove(file)
+                        }
+                      }
                     }
                     }
                   }
                   }
-                }
-              }
+                }),
+              )
             })
             })
 
 
             const diff = Effect.fnUntraced(function* (hash: string) {
             const diff = Effect.fnUntraced(function* (hash: string) {
-              yield* add()
-              const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
-                cwd: state.worktree,
-              })
-              if (result.code !== 0) {
-                log.warn("failed to get diff", {
-                  hash,
-                  exitCode: result.code,
-                  stderr: result.stderr,
-                })
-                return ""
-              }
-              return result.text.trim()
+              return yield* locked(
+                Effect.gen(function* () {
+                  yield* add()
+                  const result = yield* git(
+                    [...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
+                    {
+                      cwd: state.worktree,
+                    },
+                  )
+                  if (result.code !== 0) {
+                    log.warn("failed to get diff", {
+                      hash,
+                      exitCode: result.code,
+                      stderr: result.stderr,
+                    })
+                    return ""
+                  }
+                  return result.text.trim()
+                }),
+              )
             })
             })
 
 
             const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
             const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
-              const result: Snapshot.FileDiff[] = []
-              const status = new Map<string, "added" | "deleted" | "modified">()
-
-              const statuses = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
-                { cwd: state.directory },
-              )
+              return yield* locked(
+                Effect.gen(function* () {
+                  const result: Snapshot.FileDiff[] = []
+                  const status = new Map<string, "added" | "deleted" | "modified">()
+
+                  const statuses = yield* git(
+                    [
+                      ...quote,
+                      ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
+                    ],
+                    { cwd: state.directory },
+                  )
+
+                  for (const line of statuses.text.trim().split("\n")) {
+                    if (!line) continue
+                    const [code, file] = line.split("\t")
+                    if (!code || !file) continue
+                    status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+                  }
 
 
-              for (const line of statuses.text.trim().split("\n")) {
-                if (!line) continue
-                const [code, file] = line.split("\t")
-                if (!code || !file) continue
-                status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-              }
+                  const numstat = yield* git(
+                    [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+                    {
+                      cwd: state.directory,
+                    },
+                  )
+
+                  for (const line of numstat.text.trim().split("\n")) {
+                    if (!line) continue
+                    const [adds, dels, file] = line.split("\t")
+                    if (!file) continue
+                    const binary = adds === "-" && dels === "-"
+                    const [before, after] = binary
+                      ? ["", ""]
+                      : yield* Effect.all(
+                          [
+                            git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                            git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                          ],
+                          { concurrency: 2 },
+                        )
+                    const additions = binary ? 0 : parseInt(adds)
+                    const deletions = binary ? 0 : parseInt(dels)
+                    result.push({
+                      file,
+                      before,
+                      after,
+                      additions: Number.isFinite(additions) ? additions : 0,
+                      deletions: Number.isFinite(deletions) ? deletions : 0,
+                      status: status.get(file) ?? "modified",
+                    })
+                  }
 
 
-              const numstat = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-                {
-                  cwd: state.directory,
-                },
+                  return result
+                }),
               )
               )
-
-              for (const line of numstat.text.trim().split("\n")) {
-                if (!line) continue
-                const [adds, dels, file] = line.split("\t")
-                if (!file) continue
-                const binary = adds === "-" && dels === "-"
-                const [before, after] = binary
-                  ? ["", ""]
-                  : yield* Effect.all(
-                      [
-                        git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                        git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                      ],
-                      { concurrency: 2 },
-                    )
-                const additions = binary ? 0 : parseInt(adds)
-                const deletions = binary ? 0 : parseInt(dels)
-                result.push({
-                  file,
-                  before,
-                  after,
-                  additions: Number.isFinite(additions) ? additions : 0,
-                  deletions: Number.isFinite(deletions) ? deletions : 0,
-                  status: status.get(file) ?? "modified",
-                })
-              }
-
-              return result
             })
             })
 
 
             yield* cleanup().pipe(
             yield* cleanup().pipe(

+ 24 - 10
packages/opencode/src/storage/db.ts

@@ -27,16 +27,20 @@ export const NotFoundError = NamedError.create(
 const log = Log.create({ service: "db" })
 const log = Log.create({ service: "db" })
 
 
 export namespace Database {
 export namespace Database {
-  export const Path = iife(() => {
-    if (Flag.OPENCODE_DB) {
-      if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
-      return path.join(Global.Path.data, Flag.OPENCODE_DB)
-    }
+  export function getChannelPath() {
     const channel = Installation.CHANNEL
     const channel = Installation.CHANNEL
     if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
     if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
       return path.join(Global.Path.data, "opencode.db")
       return path.join(Global.Path.data, "opencode.db")
     const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
     const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
     return path.join(Global.Path.data, `opencode-${safe}.db`)
     return path.join(Global.Path.data, `opencode-${safe}.db`)
+  }
+
+  export const Path = iife(() => {
+    if (Flag.OPENCODE_DB) {
+      if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
+      return path.join(Global.Path.data, Flag.OPENCODE_DB)
+    }
+    return getChannelPath()
   })
   })
 
 
   export type Transaction = SQLiteTransaction<"sync", void>
   export type Transaction = SQLiteTransaction<"sync", void>
@@ -145,17 +149,27 @@ export namespace Database {
     }
     }
   }
   }
 
 
-  export function transaction<T>(callback: (tx: TxOrDb) => T): T {
+  type NotPromise<T> = T extends Promise<any> ? never : T
+
+  export function transaction<T>(
+    callback: (tx: TxOrDb) => NotPromise<T>,
+    options?: {
+      behavior?: "deferred" | "immediate" | "exclusive"
+    },
+  ): NotPromise<T> {
     try {
     try {
       return callback(ctx.use().tx)
       return callback(ctx.use().tx)
     } catch (err) {
     } catch (err) {
       if (err instanceof Context.NotFound) {
       if (err instanceof Context.NotFound) {
         const effects: (() => void | Promise<void>)[] = []
         const effects: (() => void | Promise<void>)[] = []
-        const result = (Client().transaction as any)((tx: TxOrDb) => {
-          return ctx.provide({ tx, effects }, () => callback(tx))
-        })
+        const result = Client().transaction(
+          (tx: TxOrDb) => {
+            return ctx.provide({ tx, effects }, () => callback(tx))
+          },
+          { behavior: options?.behavior },
+        )
         for (const effect of effects) effect()
         for (const effect of effects) effect()
-        return result
+        return result as NotPromise<T>
       }
       }
       throw err
       throw err
     }
     }

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

@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
 import { Lock } from "../util/lock"
 import { Lock } from "../util/lock"
 import { NamedError } from "@opencode-ai/util/error"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod"
 import z from "zod"
+import { Git } from "@/git"
 import { Glob } from "../util/glob"
 import { Glob } from "../util/glob"
-import { git } from "@/util/git"
 
 
 export namespace Storage {
 export namespace Storage {
   const log = Log.create({ service: "storage" })
   const log = Log.create({ service: "storage" })
@@ -49,7 +49,7 @@ export namespace Storage {
           }
           }
           if (!worktree) continue
           if (!worktree) continue
           if (!(await Filesystem.isDir(worktree))) continue
           if (!(await Filesystem.isDir(worktree))) continue
-          const result = await git(["rev-list", "--max-parents=0", "--all"], {
+          const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
             cwd: worktree,
             cwd: worktree,
           })
           })
           const [id] = result
           const [id] = result

+ 179 - 0
packages/opencode/src/sync/README.md

@@ -0,0 +1,179 @@
+tl;dr All of these APIs work, are properly type-checked, and are sync events are backwards compatible with `Bus`:
+
+```ts
+// The schema from `Updated` typechecks the object correctly
+SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
+
+// `subscribeAll` passes a generic sync event
+SyncEvent.subscribeAll((event) => {
+  // These will be type-checked correctly
+  event.id
+  event.seq
+  // This will be unknown because we are listening for all events,
+  // and this API is only used to record them
+  event.data
+})
+
+// This works, but you shouldn't publish sync event like this (should fail in the future)
+Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
+
+// Update event is fully type-checked
+Bus.subscribe(Updated, (event) => event.properties.info.title)
+
+// Update event is fully type-checked
+client.subscribe("session.updated", (evt) => evt.properties.info.title)
+```
+
+# Goal
+
+## Syncing with only one writer
+
+This system defines a basic event sourcing system for session replayability. The goal is to allow for one device to control and modify the session, and allow multiple other devices to "sync" session data. The sync works by getting a log of events to replay and replaying them locally.
+
+Because only one device is allowed to write, we don't need any kind of sophisticated distributed system clocks or causal ordering. We implement total ordering with a simple sequence id (a number) and increment it by one every time we generate an event.
+
+## Bus event integration and backwards compatibility
+
+This initial implementation aims to be fully backwards compatible. We should be able to land this without any visible changes to the user.
+
+An existing `Bus` abstraction to send events already exists. We already send events like `session.created` through the system. We should not duplicate this.
+
+The difference in event sourcing is events are sent _before_ the mutation happens, and "projectors" handle the effects and perform the mutations. This difference is subtle, and a necessary change for syncing to work.
+
+So the goal is:
+
+- Introduce a new syncing abstraction to handle event sourcing and projectors
+- Seamlessly integrate these new events into the same existing `Bus` abstraction
+- Maintain full backwards compatibility to reduce risk
+
+## My approach
+
+This directory introduces a new abstraction: `SyncEvent`. This handles all of the event sourcing.
+
+There are now "sync events" which are different than "bus events". Bus events are defined like this:
+
+```ts
+const Diff = BusEvent.define(
+  "session.diff",
+  z.object({
+    sessionID: SessionID.zod,
+    diff: Snapshot.FileDiff.array(),
+  }),
+)
+```
+
+You can do `Bus.publish(Diff, { ... })` to push these events, and `Bus.subscribe(Diff, handler)` to listen to them.
+
+Sync events are a lower-level abstraction which are similar, but also handle the requirements for recording and replaying. Defining them looks like this:
+
+```ts
+const Created = SyncEvent.define({
+  type: "session.created",
+  version: 1,
+  aggregate: "sessionID",
+  schema: z.object({
+    sessionID: SessionID.zod,
+    info: Info,
+  }),
+})
+```
+
+Not too different, except they track a version and an "aggregate" field (will explain that later).
+
+You do this to run an event, which is kind of like `Bus.publish` except that it runs through the event sourcing system:
+
+```
+SyncEvent.run(Created, { ... })
+```
+
+The data passed as the second argument is properly type-checked based on the schema defined in `Created`.
+
+Importantly, **sync events automatically re-publish as bus events**. This makes them backwards compatible, and allows the `Bus` to still be the single abstraction that the system uses to listen for individual events.
+
+**We have upgraded many of the session events to be sync events** (all of the ones that mutate the db). Sync and bus events are largely compatible. Here are the differences:
+
+### Event shape
+
+- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
+
+The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
+
+### Event flow
+
+There is no way to subscribe to individual sync events in `SyncEvent`. You can use `subscribeAll` to receive _all_ of the events, which is needed for clients that want to record them.
+
+To listen for individual events, use `Bus.subscribe`. You can pass in a sync event definition to it: `Bus.subscribe(Created, handler)`. This is fully supported.
+
+You should never "publish" a sync event however: `Bus.publish(Created, ...)`. I would like to force this to be a type error in the future. You should never be touching the db directly, and should not be manually handling these events.
+
+### Backwards compatibility
+
+The system install projectors in `server/projectors.js`. It calls `SyncEvent.init` to do this. It also installs a hook for dynamically converting an event at runtime (`convertEvent`).
+
+This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
+
+The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
+
+It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
+
+```ts
+// The schema from `Updated` typechecks the object correctly
+SyncEvent.run(Updated, { sessionID: id, info: { title: "foo" } })
+
+// `subscribeAll` passes a generic sync event
+SyncEvent.subscribeAll((event) => {
+  // These will be type-checked correctly
+  event.id
+  event.seq
+  // This will be unknown because we are listening for all events,
+  // and this API is only used to record them
+  event.data
+})
+
+// This works, but you shouldn't publish sync event like this (should fail in the future)
+Bus.publish(Updated, { sessionID: id, info: { title: "foo" } })
+
+// Update event is fully type-checked
+Bus.subscribe(Updated, (event) => event.properties.info.title)
+
+// Update event is fully type-checked
+client.subscribe("session.updated", (evt) => evt.properties.info.title)
+```
+
+The last two examples look similar to `SyncEvent.run`, but they were the cause of a lot of grief. Those are existing APIs that we can't break, but we are passing in the new sync event definitions to these APIs, which sometimes have a different event shape.
+
+I previously mentioned the runtime conversion of events, but we still need to the types to work! To do that, the `define` API supports an optional `busSchema` prop to give it the schema for backwards compatibility. For example this is the full definition of `Session.Update`:
+
+```ts
+const Update = SyncEvent.define({
+  type: "session.updated",
+  version: 1,
+  aggregate: "sessionID",
+  schema: z.object({
+    sessionID: SessionID.zod,
+    info: partialSchema(Info),
+  }),
+  busSchema: z.object({
+    sessionID: SessionID.zod,
+    info: Info,
+  }),
+})
+```
+
+_Important_: the conversion done in `convertEvent` is not automatically type-checked with `busSchema`. It's very important they match, but because we need this at type-checking time this needs to live here.
+
+Internally, the way this works is `busSchema` is stored on a `properties` field which is what the bus system expects. Doing this made everything with `Bus` "just work". This is why you can pass a sync event to the bus APIs.
+
+_Alternatives_
+
+These are some other paths I explored:
+
+- Providing a way to subscribe to individual sync events, and change all the instances of `Bus.subscribe` in our code to it. Then you are directly only working with sync events always.
+  - Two big problems. First, `Bus` is instance-scoped, and we'd need to make the sync event system instance-scoped too for backwards compat. If we didn't, those listeners would get calls for events they weren't expecting.
+  - Second, we can't change consumers of our SDK. So they still have to use the old events, and we might as well stick with them for consistency
+- Directly add sync event support to bus system
+  - I explored adding sync events to the bus, but due to backwards compat, it only made it more complicated (still need to support both shapes)
+- I explored a `convertSchema` function to convert the event schema at runtime so we didn't need `busSchema`
+  - Fatal flaw: we need type-checking done earlier. We can't do this at run-time. This worked for consumers of our SDK (because it gets generated TS types from the converted schema) but breaks for our internal usage of `Bus.subscribe` calls
+
+I explored many other permutations of the above solutions. What we have today I think is the best balance of backwards compatibility while opening a path forward for the new events.

+ 16 - 0
packages/opencode/src/sync/event.sql.ts

@@ -0,0 +1,16 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+
+export const EventSequenceTable = sqliteTable("event_sequence", {
+  aggregate_id: text().notNull().primaryKey(),
+  seq: integer().notNull(),
+})
+
+export const EventTable = sqliteTable("event", {
+  id: text().primaryKey(),
+  aggregate_id: text()
+    .notNull()
+    .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
+  seq: integer().notNull(),
+  type: text().notNull(),
+  data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
+})

+ 263 - 0
packages/opencode/src/sync/index.ts

@@ -0,0 +1,263 @@
+import z from "zod"
+import type { ZodObject } from "zod"
+import { EventEmitter } from "events"
+import { Database, eq } from "@/storage/db"
+import { Bus as ProjectBus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { EventSequenceTable, EventTable } from "./event.sql"
+import { EventID } from "./schema"
+import { Flag } from "@/flag/flag"
+
+export namespace SyncEvent {
+  export type Definition = {
+    type: string
+    version: number
+    aggregate: string
+    schema: z.ZodObject
+
+    // This is temporary and only exists for compatibility with bus
+    // event definitions
+    properties: z.ZodObject
+  }
+
+  export type Event<Def extends Definition = Definition> = {
+    id: string
+    seq: number
+    aggregateID: string
+    data: z.infer<Def["schema"]>
+  }
+
+  export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
+
+  type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
+
+  export const registry = new Map<string, Definition>()
+  let projectors: Map<Definition, ProjectorFunc> | undefined
+  const versions = new Map<string, number>()
+  let frozen = false
+  let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
+
+  const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
+
+  export function reset() {
+    frozen = false
+    projectors = undefined
+    convertEvent = (_, data) => data
+  }
+
+  export function init(input: { projectors: Array<[Definition, ProjectorFunc]>; convertEvent?: typeof convertEvent }) {
+    projectors = new Map(input.projectors)
+
+    // Install all the latest event defs to the bus. We only ever emit
+    // latest versions from code, and keep around old versions for
+    // replaying. Replaying does not go through the bus, and it
+    // simplifies the bus to only use unversioned latest events
+    for (let [type, version] of versions.entries()) {
+      let def = registry.get(versionedType(type, version))!
+
+      BusEvent.define(def.type, def.properties || def.schema)
+    }
+
+    // Freeze the system so it clearly errors if events are defined
+    // after `init` which would cause bugs
+    frozen = true
+    convertEvent = input.convertEvent || ((_, data) => data)
+  }
+
+  export function versionedType<A extends string>(type: A): A
+  export function versionedType<A extends string, B extends number>(type: A, version: B): `${A}/${B}`
+  export function versionedType(type: string, version?: number) {
+    return version ? `${type}.${version}` : type
+  }
+
+  export function define<
+    Type extends string,
+    Agg extends string,
+    Schema extends ZodObject<Record<Agg, z.ZodType<string>>>,
+    BusSchema extends ZodObject = Schema,
+  >(input: { type: Type; version: number; aggregate: Agg; schema: Schema; busSchema?: BusSchema }) {
+    if (frozen) {
+      throw new Error("Error defining sync event: sync system has been frozen")
+    }
+
+    const def = {
+      type: input.type,
+      version: input.version,
+      aggregate: input.aggregate,
+      schema: input.schema,
+      properties: input.busSchema ? input.busSchema : input.schema,
+    }
+
+    versions.set(def.type, Math.max(def.version, versions.get(def.type) || 0))
+
+    registry.set(versionedType(def.type, def.version), def)
+
+    return def
+  }
+
+  export function project<Def extends Definition>(
+    def: Def,
+    func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
+  ): [Definition, ProjectorFunc] {
+    return [def, func as ProjectorFunc]
+  }
+
+  function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
+    if (projectors == null) {
+      throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
+    }
+
+    const projector = projectors.get(def)
+    if (!projector) {
+      throw new Error(`Projector not found for event: ${def.type}`)
+    }
+
+    // idempotent: need to ignore any events already logged
+
+    Database.transaction((tx) => {
+      projector(tx, event.data)
+
+      if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+        tx.insert(EventSequenceTable)
+          .values({
+            aggregate_id: event.aggregateID,
+            seq: event.seq,
+          })
+          .onConflictDoUpdate({
+            target: EventSequenceTable.aggregate_id,
+            set: { seq: event.seq },
+          })
+          .run()
+        tx.insert(EventTable)
+          .values({
+            id: event.id,
+            seq: event.seq,
+            aggregate_id: event.aggregateID,
+            type: versionedType(def.type, def.version),
+            data: event.data as Record<string, unknown>,
+          })
+          .run()
+      }
+
+      Database.effect(() => {
+        Bus.emit("event", {
+          def,
+          event,
+        })
+
+        if (options?.publish) {
+          const result = convertEvent(def.type, event.data)
+          if (result instanceof Promise) {
+            result.then((data) => {
+              ProjectBus.publish({ type: def.type, properties: def.schema }, data)
+            })
+          } else {
+            ProjectBus.publish({ type: def.type, properties: def.schema }, result)
+          }
+        }
+      })
+    })
+  }
+
+  // TODO:
+  //
+  // * Support applying multiple events at one time. One transaction,
+  //   and it validets all the sequence ids
+  // * when loading events from db, apply zod validation to ensure shape
+
+  export function replay(event: SerializedEvent, options?: { republish: boolean }) {
+    const def = registry.get(event.type)
+    if (!def) {
+      throw new Error(`Unknown event type: ${event.type}`)
+    }
+
+    const row = Database.use((db) =>
+      db
+        .select({ seq: EventSequenceTable.seq })
+        .from(EventSequenceTable)
+        .where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
+        .get(),
+    )
+
+    const latest = row?.seq ?? -1
+    if (event.seq <= latest) {
+      return
+    }
+
+    const expected = latest + 1
+    if (event.seq !== expected) {
+      throw new Error(`Sequence mismatch for aggregate "${event.aggregateID}": expected ${expected}, got ${event.seq}`)
+    }
+
+    process(def, event, { publish: !!options?.republish })
+  }
+
+  export function run<Def extends Definition>(def: Def, data: Event<Def>["data"]) {
+    const agg = (data as Record<string, string>)[def.aggregate]
+    // This should never happen: we've enforced it via typescript in
+    // the definition
+    if (agg == null) {
+      throw new Error(`SyncEvent.run: "${def.aggregate}" required but not found: ${JSON.stringify(data)}`)
+    }
+
+    if (def.version !== versions.get(def.type)) {
+      throw new Error(`SyncEvent.run: running old versions of events is not allowed: ${def.type}`)
+    }
+
+    // Note that this is an "immediate" transaction which is critical.
+    // We need to make sure we can safely read and write with nothing
+    // else changing the data from under us
+    Database.transaction(
+      (tx) => {
+        const id = EventID.ascending()
+        const row = tx
+          .select({ seq: EventSequenceTable.seq })
+          .from(EventSequenceTable)
+          .where(eq(EventSequenceTable.aggregate_id, agg))
+          .get()
+        const seq = row?.seq != null ? row.seq + 1 : 0
+
+        const event = { id, seq, aggregateID: agg, data }
+        process(def, event, { publish: true })
+      },
+      {
+        behavior: "immediate",
+      },
+    )
+  }
+
+  export function remove(aggregateID: string) {
+    Database.transaction((tx) => {
+      tx.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
+      tx.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
+    })
+  }
+
+  export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
+    Bus.on("event", handler)
+    return () => Bus.off("event", handler)
+  }
+
+  export function payloads() {
+    return z
+      .union(
+        registry
+          .entries()
+          .map(([type, def]) => {
+            return z
+              .object({
+                type: z.literal(type),
+                aggregate: z.literal(def.aggregate),
+                data: def.schema,
+              })
+              .meta({
+                ref: "SyncEvent" + "." + def.type,
+              })
+          })
+          .toArray() as any,
+      )
+      .meta({
+        ref: "SyncEvent",
+      })
+  }
+}

+ 14 - 0
packages/opencode/src/sync/schema.ts

@@ -0,0 +1,14 @@
+import { Schema } from "effect"
+import z from "zod"
+
+import { Identifier } from "@/id/id"
+import { withStatics } from "@/util/schema"
+
+export const EventID = Schema.String.pipe(
+  Schema.brand("EventID"),
+  withStatics((s) => ({
+    make: (id: string) => s.makeUnsafe(id),
+    ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)),
+    zod: Identifier.schema("event").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
+  })),
+)

+ 11 - 12
packages/opencode/src/tool/task.ts

@@ -64,6 +64,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
       if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
       if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
 
 
       const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
       const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
+      const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
 
 
       const session = await iife(async () => {
       const session = await iife(async () => {
         if (params.task_id) {
         if (params.task_id) {
@@ -75,16 +76,15 @@ export const TaskTool = Tool.define("task", async (ctx) => {
           parentID: ctx.sessionID,
           parentID: ctx.sessionID,
           title: params.description + ` (@${agent.name} subagent)`,
           title: params.description + ` (@${agent.name} subagent)`,
           permission: [
           permission: [
-            {
-              permission: "todowrite",
-              pattern: "*",
-              action: "deny",
-            },
-            {
-              permission: "todoread",
-              pattern: "*",
-              action: "deny",
-            },
+            ...(hasTodoWritePermission
+              ? []
+              : [
+                  {
+                    permission: "todowrite" as const,
+                    pattern: "*" as const,
+                    action: "deny" as const,
+                  },
+                ]),
             ...(hasTaskPermission
             ...(hasTaskPermission
               ? []
               ? []
               : [
               : [
@@ -136,8 +136,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
         },
         },
         agent: agent.name,
         agent: agent.name,
         tools: {
         tools: {
-          todowrite: false,
-          todoread: false,
+          ...(hasTodoWritePermission ? {} : { todowrite: false }),
           ...(hasTaskPermission ? {} : { task: false }),
           ...(hasTaskPermission ? {} : { task: false }),
           ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
           ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
         },
         },

+ 1 - 23
packages/opencode/src/tool/todo.ts

@@ -16,7 +16,7 @@ export const TodoWriteTool = Tool.define("todowrite", {
       metadata: {},
       metadata: {},
     })
     })
 
 
-    await Todo.update({
+    Todo.update({
       sessionID: ctx.sessionID,
       sessionID: ctx.sessionID,
       todos: params.todos,
       todos: params.todos,
     })
     })
@@ -29,25 +29,3 @@ export const TodoWriteTool = Tool.define("todowrite", {
     }
     }
   },
   },
 })
 })
-
-export const TodoReadTool = Tool.define("todoread", {
-  description: "Use this tool to read your todo list",
-  parameters: z.object({}),
-  async execute(_params, ctx) {
-    await ctx.ask({
-      permission: "todoread",
-      patterns: ["*"],
-      always: ["*"],
-      metadata: {},
-    })
-
-    const todos = await Todo.get(ctx.sessionID)
-    return {
-      title: `${todos.filter((x) => x.status !== "completed").length} todos`,
-      metadata: {
-        todos,
-      },
-      output: JSON.stringify(todos, null, 2),
-    }
-  },
-})

+ 0 - 14
packages/opencode/src/tool/todoread.txt

@@ -1,14 +0,0 @@
-Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of
-the status of the current task list. You should make use of this tool as often as possible, especially in the following situations:
-- At the beginning of conversations to see what's pending
-- Before starting new tasks to prioritize work
-- When the user asks about previous tasks or plans
-- Whenever you're uncertain about what to do next
-- After completing tasks to update your understanding of remaining work
-- After every few messages to ensure you're on track
-
-Usage:
-- This tool takes in no parameters. So leave the input blank or empty. DO NOT include a dummy object, placeholder string or a key like "input" or "empty". LEAVE IT BLANK.
-- Returns a list of todo items with their status, priority, and content
-- Use this information to track progress and plan next steps
-- If no todos exist yet, an empty list will be returned

+ 0 - 35
packages/opencode/src/util/git.ts

@@ -1,35 +0,0 @@
-import { Process } from "./process"
-
-export interface GitResult {
-  exitCode: number
-  text(): string
-  stdout: Buffer
-  stderr: Buffer
-}
-
-/**
- * Run a git command.
- *
- * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
- * issues in embedded/client environments.
- */
-export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
-  return Process.run(["git", ...args], {
-    cwd: opts.cwd,
-    env: opts.env,
-    stdin: "ignore",
-    nothrow: true,
-  })
-    .then((result) => ({
-      exitCode: result.code,
-      text: () => result.stdout.toString(),
-      stdout: result.stdout,
-      stderr: result.stderr,
-    }))
-    .catch((error) => ({
-      exitCode: 1,
-      text: () => "",
-      stdout: Buffer.alloc(0),
-      stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
-    }))
-}

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio