Quellcode durchsuchen

Merge remote-tracking branch 'origin/dev' into feat/fff-search-tools

Shoubhit Dash vor 4 Monaten
Ursprung
Commit
8379612c52
59 geänderte Dateien mit 6933 neuen und 2563 gelöschten Zeilen
  1. 24 0
      .github/workflows/close-issues.yml
  2. 0 33
      .github/workflows/stale-issues.yml
  3. 7 3
      bun.lock
  4. 19 0
      packages/app/src/context/command-keybind.test.ts
  5. 3 8
      packages/app/src/entry.tsx
  6. 2 0
      packages/app/src/pages/layout.tsx
  7. 2 1
      packages/app/src/pages/session/message-timeline.tsx
  8. 2 2
      packages/app/src/pages/session/use-session-commands.tsx
  9. 1 1
      packages/desktop-electron/src/main/ipc.ts
  10. 1 1
      packages/desktop-electron/src/main/store.ts
  11. 13 0
      packages/opencode/migration/20260323234822_events/migration.sql
  12. 1271 0
      packages/opencode/migration/20260323234822_events/snapshot.json
  13. 2 2
      packages/opencode/package.json
  14. 1 1
      packages/opencode/specs/effect-migration.md
  15. 3 2
      packages/opencode/src/account/repo.ts
  16. 0 3
      packages/opencode/src/bus/bus-event.ts
  17. 1 1
      packages/opencode/src/cli/cmd/github.ts
  18. 2 2
      packages/opencode/src/cli/cmd/tui/app.tsx
  19. 1 2
      packages/opencode/src/control-plane/adaptors/worktree.ts
  20. 1 0
      packages/opencode/src/id/id.ts
  21. 6 0
      packages/opencode/src/lsp/index.ts
  22. 2 1
      packages/opencode/src/plugin/index.ts
  23. 28 0
      packages/opencode/src/server/projectors.ts
  24. 7 8
      packages/opencode/src/server/routes/event.ts
  25. 3 3
      packages/opencode/src/server/routes/experimental.ts
  26. 97 44
      packages/opencode/src/server/routes/global.ts
  27. 3 3
      packages/opencode/src/server/routes/session.ts
  28. 14 4
      packages/opencode/src/server/server.ts
  29. 98 199
      packages/opencode/src/session/index.ts
  30. 28 19
      packages/opencode/src/session/message-v2.ts
  31. 116 0
      packages/opencode/src/session/projectors.ts
  32. 6 6
      packages/opencode/src/session/revert.ts
  33. 9 10
      packages/opencode/src/share/share-next.ts
  34. 59 9
      packages/opencode/src/snapshot/index.ts
  35. 24 10
      packages/opencode/src/storage/db.ts
  36. 179 0
      packages/opencode/src/sync/README.md
  37. 16 0
      packages/opencode/src/sync/event.sql.ts
  38. 263 0
      packages/opencode/src/sync/index.ts
  39. 14 0
      packages/opencode/src/sync/schema.ts
  40. 13 0
      packages/opencode/src/util/update-schema.ts
  41. 431 472
      packages/opencode/src/worktree/index.ts
  42. 2 0
      packages/opencode/test/acp/event-subscription.test.ts
  43. 55 0
      packages/opencode/test/lsp/index.test.ts
  44. 6 0
      packages/opencode/test/preload.ts
  45. 173 0
      packages/opencode/test/project/worktree.test.ts
  46. 8 8
      packages/opencode/test/session/session.test.ts
  47. 18 1
      packages/opencode/test/snapshot/snapshot.test.ts
  48. 4 9
      packages/opencode/test/storage/db.test.ts
  49. 187 0
      packages/opencode/test/sync/index.test.ts
  50. 20 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  51. 634 533
      packages/sdk/js/src/v2/gen/types.gen.ts
  52. 910 1145
      packages/sdk/openapi.json
  53. 2 1
      packages/storybook/.storybook/main.ts
  54. 136 0
      packages/storybook/.storybook/playground-css-plugin.ts
  55. 0 5
      packages/ui/src/components/message-part.css
  56. 4 4
      packages/ui/src/components/session-turn.css
  57. 1771 0
      packages/ui/src/components/timeline-playground.stories.tsx
  58. 134 7
      script/beta.ts
  59. 97 0
      script/github/close-issues.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

@@ -330,7 +330,7 @@
         "@ff-labs/fff-node": "0.4.2",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
-        "@modelcontextprotocol/sdk": "1.25.2",
+        "@modelcontextprotocol/sdk": "1.27.1",
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
@@ -1343,7 +1343,7 @@
 
     "@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=="],
 
@@ -2927,7 +2927,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-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=="],
 
@@ -5169,6 +5169,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/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
+
     "@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=="],
@@ -6351,6 +6353,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-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/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=="],

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

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

+ 3 - 8
packages/app/src/entry.tsx

@@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
   throw new Error(getRootNotFoundError())
 }
 
-const localUrl = () =>
-  `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
-
-const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
-
 const getCurrentUrl = () => {
-  if (location.hostname.includes("opencode.ai")) return localUrl()
-  if (import.meta.env.DEV) return localUrl()
-  if (isLocalHost()) return localUrl()
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (import.meta.env.DEV)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
   return location.origin
 }
 

+ 2 - 0
packages/app/src/pages/layout.tsx

@@ -965,6 +965,8 @@ export default function Layout(props: ParentProps) {
         : projects[(index + offset + projects.length) % projects.length]
     if (!target) return
 
+    // warm up child store to prevent flicker
+    globalSync.child(target.worktree)
     openProject(target.worktree)
   }
 

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

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

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

@@ -333,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.previous",
         title: language.t("command.message.previous"),
         description: language.t("command.message.previous.description"),
-        keybind: "mod+arrowup",
+        keybind: "mod+alt+[",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(-1),
       }),
@@ -341,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.next",
         title: language.t("command.message.next"),
         description: language.t("command.message.next.description"),
-        keybind: "mod+arrowdown",
+        keybind: "mod+alt+]",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(1),
       }),

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

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

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

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

+ 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

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

+ 1 - 1
packages/opencode/specs/effect-migration.md

@@ -164,7 +164,7 @@ Still open and likely worth migrating:
 - [x] `Plugin`
 - [x] `ToolRegistry`
 - [ ] `Pty`
-- [ ] `Worktree`
+- [x] `Worktree`
 - [ ] `Bus`
 - [x] `Command`
 - [ ] `Config`

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

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

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

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

+ 1 - 1
packages/opencode/src/cli/cmd/github.ts

@@ -890,7 +890,7 @@ export const GithubRunCommand = cmd({
         }
 
         let text = ""
-        Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
+        Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
           if (evt.properties.part.sessionID !== session.id) return
           //if (evt.properties.part.messageID === messageID) return
           const part = evt.properties.part

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

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

+ 1 - 2
packages/opencode/src/control-plane/adaptors/worktree.ts

@@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
   },
   async create(info) {
     const config = Config.parse(info)
-    const bootstrap = await Worktree.createFromInfo({
+    await Worktree.createFromInfo({
       name: config.name,
       directory: config.directory,
       branch: config.branch,
     })
-    return bootstrap()
   },
   async remove(info) {
     const config = Config.parse(info)

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

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

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

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

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

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

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

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

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

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

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

@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.create.schema),
+      validator("json", Worktree.CreateInput.optional()),
       async (c) => {
         const body = c.req.valid("json")
         const worktree = await Worktree.create(body)
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.remove.schema),
+      validator("json", Worktree.RemoveInput),
       async (c) => {
         const body = c.req.valid("json")
         await Worktree.remove(body)
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.reset.schema),
+      validator("json", Worktree.ResetInput),
       async (c) => {
         const body = c.req.valid("json")
         await Worktree.reset(body)

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

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

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

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

+ 14 - 4
packages/opencode/src/server/server.ts

@@ -1,3 +1,4 @@
+import { createHash } from "node:crypto"
 import { Log } from "../util/log"
 import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
 import { Hono } from "hono"
@@ -43,10 +44,16 @@ import { PermissionRoutes } from "./routes/permission"
 import { GlobalRoutes } from "./routes/global"
 import { MDNS } from "./mdns"
 import { lazy } from "@/util/lazy"
+import { initProjectors } from "./projectors"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
 
+const csp = (hash = "") =>
+  `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
+
+initProjectors()
+
 export namespace Server {
   const log = Log.create({ service: "server" })
 
@@ -506,10 +513,13 @@ export namespace Server {
             host: "app.opencode.ai",
           },
         })
-        response.headers.set(
-          "Content-Security-Policy",
-          "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
-        )
+        const match = response.headers.get("content-type")?.includes("text/html")
+          ? (await response.clone().text()).match(
+              /<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
+            )
+          : undefined
+        const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
+        response.headers.set("Content-Security-Policy", csp(hash))
         return response
       })
   }

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

@@ -9,12 +9,14 @@ import { Config } from "../config/config"
 import { Flag } from "../flag/flag"
 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 { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { SessionTable } from "./session.sql"
 import { ProjectTable } from "../project/project.sql"
 import { Storage } from "@/storage/storage"
 import { Log } from "../util/log"
+import { updateSchema } from "../util/update-schema"
 import { MessageV2 } from "./message-v2"
 import { Instance } from "../project/instance"
 import { SessionPrompt } from "./prompt"
@@ -182,24 +184,40 @@ export namespace Session {
   export type GlobalInfo = z.output<typeof GlobalInfo>
 
   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,
       }),
-    ),
-    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,
       }),
-    ),
-    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,
       }),
-    ),
+    }),
     Diff: BusEvent.define(
       "session.diff",
       z.object({
@@ -280,18 +298,8 @@ export namespace Session {
   )
 
   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: {
@@ -318,22 +326,25 @@ export namespace Session {
       },
     }
     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()
-    if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
+    if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
       share(result.id).catch(() => {
         // 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
   }
 
@@ -357,12 +368,9 @@ export namespace Session {
     }
     const { ShareNext } = await import("@/share/share-next")
     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
   })
 
@@ -370,12 +378,8 @@ export namespace Session {
     // Use ShareNext to remove the share (same as share function uses ShareNext to create)
     const { ShareNext } = await import("@/share/share-next")
     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(
@@ -384,18 +388,7 @@ export namespace Session {
       title: z.string(),
     }),
     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(),
     }),
     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,
     }),
     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,
     }),
     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) => {
-    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,
     }),
     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) => {
-    const project = Instance.project
     try {
       const session = await get(sessionID)
       for (const child of await children(sessionID)) {
         await remove(child.id)
       }
       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) {
       log.error(e)
     }
   })
 
   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
   })
 
@@ -711,17 +639,9 @@ export namespace Session {
       messageID: MessageID.zod,
     }),
     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
     },
@@ -734,17 +654,10 @@ export namespace Session {
       partID: PartID.zod,
     }),
     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
     },
@@ -753,24 +666,10 @@ export namespace Session {
   const UpdatePartInput = MessageV2.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
   })

+ 28 - 19
packages/opencode/src/session/message-v2.ts

@@ -6,11 +6,9 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
 import { LSP } from "../lsp"
 import { Snapshot } from "@/snapshot"
 import { fn } from "@/util/fn"
+import { SyncEvent } from "../sync"
 import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
 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 { iife } from "@/util/iife"
 import type { SystemError } from "bun"
@@ -449,25 +447,34 @@ export namespace MessageV2 {
   export type Info = z.infer<typeof Info>
 
   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,
       }),
-    ),
-    Removed: BusEvent.define(
-      "message.removed",
-      z.object({
+    }),
+    Removed: SyncEvent.define({
+      type: "message.removed",
+      version: 1,
+      aggregate: "sessionID",
+      schema: z.object({
         sessionID: SessionID.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,
+        time: z.number(),
       }),
-    ),
+    }),
     PartDelta: BusEvent.define(
       "message.part.delta",
       z.object({
@@ -478,14 +485,16 @@ export namespace MessageV2 {
         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,
         messageID: MessageID.zod,
         partID: PartID.zod,
       }),
-    ),
+    }),
   }
 
   export const WithParts = z.object({

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

@@ -0,0 +1,116 @@
+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"
+
+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
+
+    db.insert(MessageTable)
+      .values({
+        id,
+        session_id: sessionID,
+        time_created,
+        data: rest,
+      })
+      .onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
+      .run()
+  }),
+
+  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
+
+    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()
+  }),
+]

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

@@ -4,8 +4,7 @@ import { Snapshot } from "../snapshot"
 import { MessageV2 } from "./message-v2"
 import { Session } from "."
 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 { Bus } from "../bus"
 import { SessionPrompt } from "./prompt"
@@ -113,8 +112,10 @@ export namespace SessionRevert {
       remove.push(msg)
     }
     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) {
       const partID = session.revert.partID
@@ -124,8 +125,7 @@ export namespace SessionRevert {
         const removeParts = target.parts.slice(removeStart)
         target.parts = preserveParts
         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,
             messageID: target.info.id,
             partID: part.id,

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

@@ -66,29 +66,28 @@ export namespace ShareNext {
   export async function init() {
     if (disabled) return
     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",
-          data: evt.properties.info,
+          data: session,
         },
       ])
     })
     Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
-      await sync(evt.properties.info.sessionID, [
+      const info = evt.properties.info
+      await sync(info.sessionID, [
         {
           type: "message",
           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",
-            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)],
           },
         ])
       }

+ 59 - 9
packages/opencode/src/snapshot/index.ts

@@ -34,6 +34,7 @@ export namespace Snapshot {
 
   const log = Log.create({ service: "snapshot" })
   const prune = "7.days"
+  const limit = 2 * 1024 * 1024
   const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
   const cfg = ["-c", "core.autocrlf=false", ...core]
   const quote = [...cfg, "-c", "core.quotepath=false"]
@@ -123,20 +124,69 @@ export namespace Snapshot {
               return file
             })
 
-            const sync = Effect.fnUntraced(function* () {
+            const sync = Effect.fnUntraced(function* (list: string[] = []) {
               const file = yield* excludes()
               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)
-              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* () {
               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 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) {
+                log.warn("failed to add snapshot files", {
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+              }
             })
 
             const cleanup = Effect.fnUntraced(function* () {
@@ -177,7 +227,7 @@ export namespace Snapshot {
             const patch = Effect.fnUntraced(function* (hash: string) {
               yield* add()
               const result = yield* git(
-                [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
                 {
                   cwd: state.directory,
                 },
@@ -245,7 +295,7 @@ export namespace Snapshot {
 
             const diff = Effect.fnUntraced(function* (hash: string) {
               yield* add()
-              const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
+              const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
                 cwd: state.worktree,
               })
               if (result.code !== 0) {

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

@@ -27,16 +27,20 @@ export const NotFoundError = NamedError.create(
 const log = Log.create({ service: "db" })
 
 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
     if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
       return path.join(Global.Path.data, "opencode.db")
     const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
     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>
@@ -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 {
       return callback(ctx.use().tx)
     } catch (err) {
       if (err instanceof Context.NotFound) {
         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()
-        return result
+        return result as NotPromise<T>
       }
       throw err
     }

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

+ 13 - 0
packages/opencode/src/util/update-schema.ts

@@ -0,0 +1,13 @@
+import z from "zod"
+
+export function updateSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
+  const next = {} as {
+    [K in keyof T]: z.ZodOptional<z.ZodNullable<T[K]>>
+  }
+
+  for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) {
+    next[k] = v.nullable() as unknown as (typeof next)[typeof k]
+  }
+
+  return z.object(next)
+}

+ 431 - 472
packages/opencode/src/worktree/index.ts

@@ -1,5 +1,3 @@
-import fs from "fs/promises"
-import path from "path"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { Global } from "../global"
@@ -9,12 +7,15 @@ import { Project } from "../project/project"
 import { Database, eq } from "../storage/db"
 import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"
-import { fn } from "../util/fn"
 import { Log } from "../util/log"
-import { Process } from "../util/process"
-import { git } from "../util/git"
+import { Slug } from "@opencode-ai/util/slug"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
+import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { makeRunPromise } from "@/effect/run-service"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
 export namespace Worktree {
   const log = Log.create({ service: "worktree" })
@@ -123,77 +124,7 @@ export namespace Worktree {
     }),
   )
 
-  const ADJECTIVES = [
-    "brave",
-    "calm",
-    "clever",
-    "cosmic",
-    "crisp",
-    "curious",
-    "eager",
-    "gentle",
-    "glowing",
-    "happy",
-    "hidden",
-    "jolly",
-    "kind",
-    "lucky",
-    "mighty",
-    "misty",
-    "neon",
-    "nimble",
-    "playful",
-    "proud",
-    "quick",
-    "quiet",
-    "shiny",
-    "silent",
-    "stellar",
-    "sunny",
-    "swift",
-    "tidy",
-    "witty",
-  ] as const
-
-  const NOUNS = [
-    "cabin",
-    "cactus",
-    "canyon",
-    "circuit",
-    "comet",
-    "eagle",
-    "engine",
-    "falcon",
-    "forest",
-    "garden",
-    "harbor",
-    "island",
-    "knight",
-    "lagoon",
-    "meadow",
-    "moon",
-    "mountain",
-    "nebula",
-    "orchid",
-    "otter",
-    "panda",
-    "pixel",
-    "planet",
-    "river",
-    "rocket",
-    "sailor",
-    "squid",
-    "star",
-    "tiger",
-    "wizard",
-    "wolf",
-  ] as const
-
-  function pick<const T extends readonly string[]>(list: T) {
-    return list[Math.floor(Math.random() * list.length)]
-  }
-
-  function slug(input: string) {
+  function slugify(input: string) {
     return input
       .trim()
       .toLowerCase()
@@ -202,28 +133,8 @@ export namespace Worktree {
       .replace(/-+$/, "")
   }
 
-  function randomName() {
-    return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
-  }
-
-  async function exists(target: string) {
-    return fs
-      .stat(target)
-      .then(() => true)
-      .catch(() => false)
-  }
-
-  function outputText(input: Uint8Array | undefined) {
-    if (!input?.length) return ""
-    return new TextDecoder().decode(input).trim()
-  }
-
-  function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
-    return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
-  }
-
-  function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
-    return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
+  function failedRemoves(...chunks: string[]) {
+    return chunks.filter(Boolean).flatMap((chunk) =>
       chunk
         .split("\n")
         .map((line) => line.trim())
@@ -237,436 +148,484 @@ export namespace Worktree {
     )
   }
 
-  async function prune(root: string, entries: string[]) {
-    const base = await canonical(root)
-    await Promise.all(
-      entries.map(async (entry) => {
-        const target = await canonical(path.resolve(root, entry))
-        if (target === base) return
-        if (!target.startsWith(`${base}${path.sep}`)) return
-        await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
-      }),
-    )
-  }
-
-  async function sweep(root: string) {
-    const first = await git(["clean", "-ffdx"], { cwd: root })
-    if (first.exitCode === 0) return first
-
-    const entries = failed(first)
-    if (!entries.length) return first
-
-    await prune(root, entries)
-    return git(["clean", "-ffdx"], { cwd: root })
-  }
+  // ---------------------------------------------------------------------------
+  // Effect service
+  // ---------------------------------------------------------------------------
 
-  async function canonical(input: string) {
-    const abs = path.resolve(input)
-    const real = await fs.realpath(abs).catch(() => abs)
-    const normalized = path.normalize(real)
-    return process.platform === "win32" ? normalized.toLowerCase() : normalized
+  export interface Interface {
+    readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
+    readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
+    readonly create: (input?: CreateInput) => Effect.Effect<Info>
+    readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
+    readonly reset: (input: ResetInput) => Effect.Effect<boolean>
   }
 
-  async function candidate(root: string, base?: string) {
-    for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
-      const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
-      const branch = `opencode/${name}`
-      const directory = path.join(root, name)
-
-      if (await exists(directory)) continue
-
-      const ref = `refs/heads/${branch}`
-      const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
-        cwd: Instance.worktree,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
+
+  type GitResult = { code: number; text: string; stderr: string }
+
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const scope = yield* Scope.Scope
+      const fsys = yield* FileSystem.FileSystem
+      const pathSvc = yield* Path.Path
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+
+      const git = Effect.fnUntraced(
+        function* (args: string[], opts?: { cwd?: string }) {
+          const handle = yield* spawner.spawn(
+            ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
+          )
+          const [text, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          const code = yield* handle.exitCode
+          return { code, text, stderr } satisfies GitResult
+        },
+        Effect.scoped,
+        Effect.catch((e) =>
+          Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
+        ),
+      )
+
+      const MAX_NAME_ATTEMPTS = 26
+      const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
+        for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
+          const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
+          const branch = `opencode/${name}`
+          const directory = pathSvc.join(root, name)
+
+          if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
+
+          const ref = `refs/heads/${branch}`
+          const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
+          if (branchCheck.code === 0) continue
+
+          return Info.parse({ name, branch, directory })
+        }
+        throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
       })
-      if (branchCheck.exitCode === 0) continue
-
-      return Info.parse({ name, branch, directory })
-    }
-
-    throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
-  }
-
-  async function runStartCommand(directory: string, cmd: string) {
-    if (process.platform === "win32") {
-      return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
-    }
-    return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
-  }
 
-  type StartKind = "project" | "worktree"
-
-  async function runStartScript(directory: string, cmd: string, kind: StartKind) {
-    const text = cmd.trim()
-    if (!text) return true
-
-    const ran = await runStartCommand(directory, text)
-    if (ran.code === 0) return true
-
-    log.error("worktree start command failed", {
-      kind,
-      directory,
-      message: errorText(ran),
-    })
-    return false
-  }
-
-  async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
-    const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
-    const project = row ? Project.fromRow(row) : undefined
-    const startup = project?.commands?.start?.trim() ?? ""
-    const ok = await runStartScript(directory, startup, "project")
-    if (!ok) return false
-
-    const extra = input.extra ?? ""
-    await runStartScript(directory, extra, "worktree")
-    return true
-  }
+      const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-  function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
-    setTimeout(() => {
-      const start = async () => {
-        await runStartScripts(directory, input)
-      }
+        const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
+        yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
 
-      void start().catch((error) => {
-        log.error("worktree start task failed", { directory, error })
+        const base = name ? slugify(name) : ""
+        return yield* candidate(root, base || undefined)
       })
-    }, 0)
-  }
-
-  export async function makeWorktreeInfo(name?: string): Promise<Info> {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const root = path.join(Global.Path.data, "worktree", Instance.project.id)
-    await fs.mkdir(root, { recursive: true })
 
-    const base = name ? slug(name) : ""
-    return candidate(root, base || undefined)
-  }
-
-  export async function createFromInfo(info: Info, startCommand?: string) {
-    const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
-      cwd: Instance.worktree,
-    })
-    if (created.exitCode !== 0) {
-      throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
-    }
+      const setup = Effect.fnUntraced(function* (info: Info) {
+        const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
+          cwd: Instance.worktree,
+        })
+        if (created.code !== 0) {
+          throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
+        }
 
-    await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
+        yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
+      })
 
-    const projectID = Instance.project.id
-    const extra = startCommand?.trim()
+      const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
+        const projectID = Instance.project.id
+        const extra = startCommand?.trim()
 
-    return () => {
-      const start = async () => {
-        const populated = await git(["reset", "--hard"], { cwd: info.directory })
-        if (populated.exitCode !== 0) {
-          const message = errorText(populated) || "Failed to populate worktree"
+        const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
+        if (populated.code !== 0) {
+          const message = populated.stderr || populated.text || "Failed to populate worktree"
           log.error("worktree checkout failed", { directory: info.directory, message })
           GlobalBus.emit("event", {
             directory: info.directory,
-            payload: {
-              type: Event.Failed.type,
-              properties: {
-                message,
-              },
-            },
+            payload: { type: Event.Failed.type, properties: { message } },
           })
           return
         }
 
-        const booted = await Instance.provide({
-          directory: info.directory,
-          init: InstanceBootstrap,
-          fn: () => undefined,
-        })
-          .then(() => true)
-          .catch((error) => {
-            const message = error instanceof Error ? error.message : String(error)
-            log.error("worktree bootstrap failed", { directory: info.directory, message })
-            GlobalBus.emit("event", {
-              directory: info.directory,
-              payload: {
-                type: Event.Failed.type,
-                properties: {
-                  message,
-                },
-              },
-            })
-            return false
+        const booted = yield* Effect.promise(() =>
+          Instance.provide({
+            directory: info.directory,
+            init: InstanceBootstrap,
+            fn: () => undefined,
           })
+            .then(() => true)
+            .catch((error) => {
+              const message = error instanceof Error ? error.message : String(error)
+              log.error("worktree bootstrap failed", { directory: info.directory, message })
+              GlobalBus.emit("event", {
+                directory: info.directory,
+                payload: { type: Event.Failed.type, properties: { message } },
+              })
+              return false
+            }),
+        )
         if (!booted) return
 
         GlobalBus.emit("event", {
           directory: info.directory,
           payload: {
             type: Event.Ready.type,
-            properties: {
-              name: info.name,
-              branch: info.branch,
-            },
+            properties: { name: info.name, branch: info.branch },
           },
         })
 
-        await runStartScripts(info.directory, { projectID, extra })
-      }
+        yield* runStartScripts(info.directory, { projectID, extra })
+      })
 
-      return start().catch((error) => {
-        log.error("worktree start task failed", { directory: info.directory, error })
+      const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
+        yield* setup(info)
+        yield* boot(info, startCommand)
       })
-    }
-  }
 
-  export const create = fn(CreateInput.optional(), async (input) => {
-    const info = await makeWorktreeInfo(input?.name)
-    const bootstrap = await createFromInfo(info, input?.startCommand)
-    // This is needed due to how worktrees currently work in the
-    // desktop app
-    setTimeout(() => {
-      bootstrap()
-    }, 0)
-    return info
-  })
-
-  export const remove = fn(RemoveInput, async (input) => {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const directory = await canonical(input.directory)
-    const locate = async (stdout: Uint8Array | undefined) => {
-      const lines = outputText(stdout)
-        .split("\n")
-        .map((line) => line.trim())
-      const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
-        if (!line) return acc
-        if (line.startsWith("worktree ")) {
-          acc.push({ path: line.slice("worktree ".length).trim() })
-          return acc
-        }
-        const current = acc[acc.length - 1]
-        if (!current) return acc
-        if (line.startsWith("branch ")) {
-          current.branch = line.slice("branch ".length).trim()
-        }
-        return acc
-      }, [])
+      const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
+        const info = yield* makeWorktreeInfo(input?.name)
+        yield* setup(info)
+        yield* boot(info, input?.startCommand).pipe(
+          Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
+          Effect.forkIn(scope),
+        )
+        return info
+      })
+
+      const canonical = Effect.fnUntraced(function* (input: string) {
+        const abs = pathSvc.resolve(input)
+        const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
+        const normalized = pathSvc.normalize(real)
+        return process.platform === "win32" ? normalized.toLowerCase() : normalized
+      })
+
+      function parseWorktreeList(text: string) {
+        return text
+          .split("\n")
+          .map((line) => line.trim())
+          .reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+            if (!line) return acc
+            if (line.startsWith("worktree ")) {
+              acc.push({ path: line.slice("worktree ".length).trim() })
+              return acc
+            }
+            const current = acc[acc.length - 1]
+            if (!current) return acc
+            if (line.startsWith("branch ")) {
+              current.branch = line.slice("branch ".length).trim()
+            }
+            return acc
+          }, [])
+      }
 
-      return (async () => {
+      const locateWorktree = Effect.fnUntraced(function* (
+        entries: { path?: string; branch?: string }[],
+        directory: string,
+      ) {
         for (const item of entries) {
           if (!item.path) continue
-          const key = await canonical(item.path)
+          const key = yield* canonical(item.path)
           if (key === directory) return item
         }
-      })()
-    }
-
-    const clean = (target: string) =>
-      fs
-        .rm(target, {
-          recursive: true,
-          force: true,
-          maxRetries: 5,
-          retryDelay: 100,
-        })
-        .catch((error) => {
-          const message = error instanceof Error ? error.message : String(error)
-          throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
-        })
+        return undefined
+      })
+
+      function stopFsmonitor(target: string) {
+        return fsys.exists(target).pipe(
+          Effect.orDie,
+          Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
+        )
+      }
 
-    const stop = async (target: string) => {
-      if (!(await exists(target))) return
-      await git(["fsmonitor--daemon", "stop"], { cwd: target })
-    }
+      function cleanDirectory(target: string) {
+        return Effect.promise(() =>
+          import("fs/promises").then((fsp) =>
+            fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
+          ),
+        )
+      }
 
-    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-    if (list.exitCode !== 0) {
-      throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
-    }
+      const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-    const entry = await locate(list.stdout)
+        const directory = yield* canonical(input.directory)
 
-    if (!entry?.path) {
-      const directoryExists = await exists(directory)
-      if (directoryExists) {
-        await stop(directory)
-        await clean(directory)
-      }
-      return true
-    }
+        const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+        if (list.code !== 0) {
+          throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
+        }
 
-    await stop(entry.path)
-    const removed = await git(["worktree", "remove", "--force", entry.path], {
-      cwd: Instance.worktree,
-    })
-    if (removed.exitCode !== 0) {
-      const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-      if (next.exitCode !== 0) {
-        throw new RemoveFailedError({
-          message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
-        })
-      }
+        const entries = parseWorktreeList(list.text)
+        const entry = yield* locateWorktree(entries, directory)
 
-      const stale = await locate(next.stdout)
-      if (stale?.path) {
-        throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
-      }
-    }
+        if (!entry?.path) {
+          const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
+          if (directoryExists) {
+            yield* stopFsmonitor(directory)
+            yield* cleanDirectory(directory)
+          }
+          return true
+        }
 
-    await clean(entry.path)
+        yield* stopFsmonitor(entry.path)
+        const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
+        if (removed.code !== 0) {
+          const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+          if (next.code !== 0) {
+            throw new RemoveFailedError({
+              message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
+            })
+          }
 
-    const branch = entry.branch?.replace(/^refs\/heads\//, "")
-    if (branch) {
-      const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
-      if (deleted.exitCode !== 0) {
-        throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
-      }
-    }
-
-    return true
-  })
-
-  export const reset = fn(ResetInput, async (input) => {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const directory = await canonical(input.directory)
-    const primary = await canonical(Instance.worktree)
-    if (directory === primary) {
-      throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
-    }
-
-    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-    if (list.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
-    }
-
-    const lines = outputText(list.stdout)
-      .split("\n")
-      .map((line) => line.trim())
-    const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
-      if (!line) return acc
-      if (line.startsWith("worktree ")) {
-        acc.push({ path: line.slice("worktree ".length).trim() })
-        return acc
-      }
-      const current = acc[acc.length - 1]
-      if (!current) return acc
-      if (line.startsWith("branch ")) {
-        current.branch = line.slice("branch ".length).trim()
-      }
-      return acc
-    }, [])
-
-    const entry = await (async () => {
-      for (const item of entries) {
-        if (!item.path) continue
-        const key = await canonical(item.path)
-        if (key === directory) return item
-      }
-    })()
-    if (!entry?.path) {
-      throw new ResetFailedError({ message: "Worktree not found" })
-    }
-
-    const remoteList = await git(["remote"], { cwd: Instance.worktree })
-    if (remoteList.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
-    }
-
-    const remotes = outputText(remoteList.stdout)
-      .split("\n")
-      .map((line) => line.trim())
-      .filter(Boolean)
-
-    const remote = remotes.includes("origin")
-      ? "origin"
-      : remotes.length === 1
-        ? remotes[0]
-        : remotes.includes("upstream")
-          ? "upstream"
-          : ""
-
-    const remoteHead = remote
-      ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
-      : { exitCode: 1, stdout: undefined, stderr: undefined }
-
-    const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
-    const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
-    const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
-
-    const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
-      cwd: Instance.worktree,
-    })
-    const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
-      cwd: Instance.worktree,
-    })
-    const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
+          const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
+          if (stale?.path) {
+            throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
+          }
+        }
 
-    const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
-    if (!target) {
-      throw new ResetFailedError({ message: "Default branch not found" })
-    }
+        yield* cleanDirectory(entry.path)
 
-    if (remoteBranch) {
-      const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
-      if (fetch.exitCode !== 0) {
-        throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
-      }
-    }
+        const branch = entry.branch?.replace(/^refs\/heads\//, "")
+        if (branch) {
+          const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
+          if (deleted.code !== 0) {
+            throw new RemoveFailedError({
+              message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
+            })
+          }
+        }
+
+        return true
+      })
+
+      const gitExpect = Effect.fnUntraced(function* (
+        args: string[],
+        opts: { cwd: string },
+        error: (r: GitResult) => Error,
+      ) {
+        const result = yield* git(args, opts)
+        if (result.code !== 0) throw error(result)
+        return result
+      })
+
+      const runStartCommand = Effect.fnUntraced(
+        function* (directory: string, cmd: string) {
+          const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
+          const handle = yield* spawner.spawn(
+            ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
+          )
+          // Drain stdout, capture stderr for error reporting
+          const [, stderr] = yield* Effect.all(
+            [Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          ).pipe(Effect.orDie)
+          const code = yield* handle.exitCode
+          return { code, stderr }
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
+      )
+
+      const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
+        const text = cmd.trim()
+        if (!text) return true
+        const result = yield* runStartCommand(directory, text)
+        if (result.code === 0) return true
+        log.error("worktree start command failed", { kind, directory, message: result.stderr })
+        return false
+      })
 
-    if (!entry.path) {
-      throw new ResetFailedError({ message: "Worktree path not found" })
-    }
+      const runStartScripts = Effect.fnUntraced(function* (
+        directory: string,
+        input: { projectID: ProjectID; extra?: string },
+      ) {
+        const row = yield* Effect.sync(() =>
+          Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
+        )
+        const project = row ? Project.fromRow(row) : undefined
+        const startup = project?.commands?.start?.trim() ?? ""
+        const ok = yield* runStartScript(directory, startup, "project")
+        if (!ok) return false
+        yield* runStartScript(directory, input.extra ?? "", "worktree")
+        return true
+      })
 
-    const worktreePath = entry.path
+      const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
+        const base = yield* canonical(root)
+        yield* Effect.forEach(
+          entries,
+          (entry) =>
+            Effect.gen(function* () {
+              const target = yield* canonical(pathSvc.resolve(root, entry))
+              if (target === base) return
+              if (!target.startsWith(`${base}${pathSvc.sep}`)) return
+              yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
+            }),
+          { concurrency: "unbounded" },
+        )
+      })
 
-    const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
-    if (resetToTarget.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
-    }
+      const sweep = Effect.fnUntraced(function* (root: string) {
+        const first = yield* git(["clean", "-ffdx"], { cwd: root })
+        if (first.code === 0) return first
 
-    const clean = await sweep(worktreePath)
-    if (clean.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
-    }
+        const entries = failedRemoves(first.stderr, first.text)
+        if (!entries.length) return first
 
-    const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
-    if (update.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
-    }
+        yield* prune(root, entries)
+        return yield* git(["clean", "-ffdx"], { cwd: root })
+      })
 
-    const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
-      cwd: worktreePath,
-    })
-    if (subReset.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
-    }
+      const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-    const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
-      cwd: worktreePath,
-    })
-    if (subClean.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
-    }
+        const directory = yield* canonical(input.directory)
+        const primary = yield* canonical(Instance.worktree)
+        if (directory === primary) {
+          throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
+        }
 
-    const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
-    if (status.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
-    }
+        const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+        if (list.code !== 0) {
+          throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
+        }
+
+        const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
+        if (!entry?.path) {
+          throw new ResetFailedError({ message: "Worktree not found" })
+        }
 
-    const dirty = outputText(status.stdout)
-    if (dirty) {
-      throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
-    }
+        const worktreePath = entry.path
+
+        const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
+        if (remoteList.code !== 0) {
+          throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
+        }
+
+        const remotes = remoteList.text
+          .split("\n")
+          .map((l) => l.trim())
+          .filter(Boolean)
+        const remote = remotes.includes("origin")
+          ? "origin"
+          : remotes.length === 1
+            ? remotes[0]
+            : remotes.includes("upstream")
+              ? "upstream"
+              : ""
+
+        const remoteHead = remote
+          ? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
+          : { code: 1, text: "", stderr: "" }
+
+        const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
+        const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
+        const remoteBranch =
+          remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
+
+        const [mainCheck, masterCheck] = yield* Effect.all(
+          [
+            git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
+            git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
+          ],
+          { concurrency: 2 },
+        )
+        const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
+
+        const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
+        if (!target) {
+          throw new ResetFailedError({ message: "Default branch not found" })
+        }
+
+        if (remoteBranch) {
+          yield* gitExpect(
+            ["fetch", remote, remoteBranch],
+            { cwd: Instance.worktree },
+            (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
+          )
+        }
 
-    const projectID = Instance.project.id
-    queueStartScripts(worktreePath, { projectID })
+        yield* gitExpect(
+          ["reset", "--hard", target],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
+        )
 
-    return true
-  })
+        const cleanResult = yield* sweep(worktreePath)
+        if (cleanResult.code !== 0) {
+          throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
+        }
+
+        yield* gitExpect(
+          ["submodule", "update", "--init", "--recursive", "--force"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
+        )
+
+        yield* gitExpect(
+          ["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
+        )
+
+        yield* gitExpect(
+          ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
+        )
+
+        const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
+        if (status.code !== 0) {
+          throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
+        }
+
+        if (status.text.trim()) {
+          throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
+        }
+
+        yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
+          Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
+          Effect.forkIn(scope),
+        )
+
+        return true
+      })
+
+      return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
+    }),
+  )
+
+  const defaultLayer = layer.pipe(
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  export async function makeWorktreeInfo(name?: string) {
+    return runPromise((svc) => svc.makeWorktreeInfo(name))
+  }
+
+  export async function createFromInfo(info: Info, startCommand?: string) {
+    return runPromise((svc) => svc.createFromInfo(info, startCommand))
+  }
+
+  export async function create(input?: CreateInput) {
+    return runPromise((svc) => svc.create(input))
+  }
+
+  export async function remove(input: RemoveInput) {
+    return runPromise((svc) => svc.remove(input))
+  }
+
+  export async function reset(input: ResetInput) {
+    return runPromise((svc) => svc.reset(input))
+  }
 }

+ 2 - 0
packages/opencode/test/acp/event-subscription.test.ts

@@ -60,6 +60,8 @@ function toolEvent(
   const payload: EventMessagePartUpdated = {
     type: "message.part.updated",
     properties: {
+      sessionID: sessionId,
+      time: Date.now(),
       part: {
         id: `part_${opts.callID}`,
         sessionID: sessionId,

+ 55 - 0
packages/opencode/test/lsp/index.test.ts

@@ -0,0 +1,55 @@
+import { describe, expect, spyOn, test } from "bun:test"
+import path from "path"
+import * as Lsp from "../../src/lsp/index"
+import { LSPServer } from "../../src/lsp/server"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("lsp.spawn", () => {
+  test("does not spawn builtin LSP for files outside instance", async () => {
+    await using tmp = await tmpdir()
+    const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
+
+    try {
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
+          await Lsp.LSP.hover({
+            file: path.join(tmp.path, "..", "hover.ts"),
+            line: 0,
+            character: 0,
+          })
+        },
+      })
+
+      expect(spy).toHaveBeenCalledTimes(0)
+    } finally {
+      spy.mockRestore()
+      await Instance.disposeAll()
+    }
+  })
+
+  test("would spawn builtin LSP for files inside instance", async () => {
+    await using tmp = await tmpdir()
+    const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
+
+    try {
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await Lsp.LSP.hover({
+            file: path.join(tmp.path, "src", "inside.ts"),
+            line: 0,
+            character: 0,
+          })
+        },
+      })
+
+      expect(spy).toHaveBeenCalledTimes(1)
+    } finally {
+      spy.mockRestore()
+      await Instance.disposeAll()
+    }
+  })
+})

+ 6 - 0
packages/opencode/test/preload.ts

@@ -74,11 +74,17 @@ delete process.env["SAMBANOVA_API_KEY"]
 delete process.env["OPENCODE_SERVER_PASSWORD"]
 delete process.env["OPENCODE_SERVER_USERNAME"]
 
+// Use in-memory sqlite
+process.env["OPENCODE_DB"] = ":memory:"
+
 // Now safe to import from src/
 const { Log } = await import("../src/util/log")
+const { initProjectors } = await import("../src/server/projectors")
 
 Log.init({
   print: false,
   dev: true,
   level: "DEBUG",
 })
+
+initProjectors()

+ 173 - 0
packages/opencode/test/project/worktree.test.ts

@@ -0,0 +1,173 @@
+import { $ } from "bun"
+import { afterEach, describe, expect, test } from "bun:test"
+
+const wintest = process.platform !== "win32" ? test : test.skip
+import fs from "fs/promises"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { Worktree } from "../../src/worktree"
+import { tmpdir } from "../fixture/fixture"
+
+function withInstance(directory: string, fn: () => Promise<any>) {
+  return Instance.provide({ directory, fn })
+}
+
+function normalize(input: string) {
+  return input.replace(/\\/g, "/").toLowerCase()
+}
+
+async function waitReady() {
+  const { GlobalBus } = await import("../../src/bus/global")
+
+  return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
+    const timer = setTimeout(() => {
+      GlobalBus.off("event", on)
+      reject(new Error("timed out waiting for worktree.ready"))
+    }, 10_000)
+
+    function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
+      if (evt.payload.type !== Worktree.Event.Ready.type) return
+      clearTimeout(timer)
+      GlobalBus.off("event", on)
+      resolve(evt.payload.properties)
+    }
+
+    GlobalBus.on("event", on)
+  })
+}
+
+describe("Worktree", () => {
+  afterEach(() => Instance.disposeAll())
+
+  describe("makeWorktreeInfo", () => {
+    test("returns info with name, branch, and directory", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
+
+      expect(info.name).toBeDefined()
+      expect(typeof info.name).toBe("string")
+      expect(info.branch).toBe(`opencode/${info.name}`)
+      expect(info.directory).toContain(info.name)
+    })
+
+    test("uses provided name as base", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
+
+      expect(info.name).toBe("my-feature")
+      expect(info.branch).toBe("opencode/my-feature")
+    })
+
+    test("slugifies the provided name", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
+
+      expect(info.name).toBe("my-feature-branch")
+    })
+
+    test("throws NotGitError for non-git directories", async () => {
+      await using tmp = await tmpdir()
+
+      await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
+    })
+  })
+
+  describe("create + remove lifecycle", () => {
+    test("create returns worktree info and remove cleans up", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.create())
+
+      expect(info.name).toBeDefined()
+      expect(info.branch).toStartWith("opencode/")
+      expect(info.directory).toBeDefined()
+
+      // Wait for bootstrap to complete
+      await Bun.sleep(1000)
+
+      const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+      expect(ok).toBe(true)
+    })
+
+    test("create returns after setup and fires Event.Ready after bootstrap", async () => {
+      await using tmp = await tmpdir({ git: true })
+      const ready = waitReady()
+
+      const info = await withInstance(tmp.path, () => Worktree.create())
+
+      // create returns before bootstrap completes, but the worktree already exists
+      expect(info.name).toBeDefined()
+      expect(info.branch).toStartWith("opencode/")
+
+      const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
+      const dir = await fs.realpath(info.directory).catch(() => info.directory)
+      expect(normalize(text)).toContain(normalize(dir))
+
+      // Event.Ready fires after bootstrap finishes in the background
+      const props = await ready
+      expect(props.name).toBe(info.name)
+      expect(props.branch).toBe(info.branch)
+
+      // Cleanup
+      await withInstance(info.directory, () => Instance.dispose())
+      await Bun.sleep(100)
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+
+    test("create with custom name", async () => {
+      await using tmp = await tmpdir({ git: true })
+      const ready = waitReady()
+
+      const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
+
+      expect(info.name).toBe("test-workspace")
+      expect(info.branch).toBe("opencode/test-workspace")
+
+      // Cleanup
+      await ready
+      await withInstance(info.directory, () => Instance.dispose())
+      await Bun.sleep(100)
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+  })
+
+  describe("createFromInfo", () => {
+    wintest("creates and bootstraps git worktree", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
+      await withInstance(tmp.path, () => Worktree.createFromInfo(info))
+
+      // Worktree should exist in git (normalize slashes for Windows)
+      const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
+      const normalizedList = list.replace(/\\/g, "/")
+      const normalizedDir = info.directory.replace(/\\/g, "/")
+      expect(normalizedList).toContain(normalizedDir)
+
+      // Cleanup
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+  })
+
+  describe("remove edge cases", () => {
+    test("remove non-existent directory succeeds silently", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const ok = await withInstance(tmp.path, () =>
+        Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
+      )
+      expect(ok).toBe(true)
+    })
+
+    test("throws NotGitError for non-git directories", async () => {
+      await using tmp = await tmpdir()
+
+      await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
+        "WorktreeNotGitError",
+      )
+    })
+  })
+})

+ 8 - 8
packages/opencode/test/session/session.test.ts

@@ -10,8 +10,8 @@ import { MessageID, PartID } from "../../src/session/schema"
 const projectRoot = path.join(__dirname, "../..")
 Log.init({ print: false })
 
-describe("session.started event", () => {
-  test("should emit session.started event when session is created", async () => {
+describe("session.created event", () => {
+  test("should emit session.created event when session is created", async () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
@@ -41,14 +41,14 @@ describe("session.started event", () => {
     })
   })
 
-  test("session.started event should be emitted before session.updated", async () => {
+  test("session.created event should be emitted before session.updated", async () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
         const events: string[] = []
 
-        const unsubStarted = Bus.subscribe(Session.Event.Created, () => {
-          events.push("started")
+        const unsubCreated = Bus.subscribe(Session.Event.Created, () => {
+          events.push("created")
         })
 
         const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
@@ -59,12 +59,12 @@ describe("session.started event", () => {
 
         await new Promise((resolve) => setTimeout(resolve, 100))
 
-        unsubStarted()
+        unsubCreated()
         unsubUpdated()
 
-        expect(events).toContain("started")
+        expect(events).toContain("created")
         expect(events).toContain("updated")
-        expect(events.indexOf("started")).toBeLessThan(events.indexOf("updated"))
+        expect(events.indexOf("created")).toBeLessThan(events.indexOf("updated"))
 
         await Session.remove(session.id)
       },

+ 18 - 1
packages/opencode/test/snapshot/snapshot.test.ts

@@ -181,7 +181,7 @@ test("symlink handling", async () => {
   })
 })
 
-test("large file handling", async () => {
+test("file under size limit handling", async () => {
   await using tmp = await bootstrap()
   await Instance.provide({
     directory: tmp.path,
@@ -196,6 +196,23 @@ test("large file handling", async () => {
   })
 })
 
+test("large added files are skipped", async () => {
+  await using tmp = await bootstrap()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const before = await Snapshot.track()
+      expect(before).toBeTruthy()
+
+      await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
+
+      expect((await Snapshot.patch(before!)).files).toEqual([])
+      expect(await Snapshot.diff(before!)).toBe("")
+      expect(await Snapshot.track()).toBe(before)
+    },
+  })
+})
+
 test("nested directory revert", async () => {
   await using tmp = await bootstrap()
   await Instance.provide({

+ 4 - 9
packages/opencode/test/storage/db.test.ts

@@ -6,14 +6,9 @@ import { Database } from "../../src/storage/db"
 
 describe("Database.Path", () => {
   test("returns database path for the current channel", () => {
-    const db = process.env["OPENCODE_DB"]
-    const expected = db
-      ? path.isAbsolute(db)
-        ? db
-        : path.join(Global.Path.data, db)
-      : ["latest", "beta"].includes(Installation.CHANNEL)
-        ? path.join(Global.Path.data, "opencode.db")
-        : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
-    expect(Database.Path).toBe(expected)
+    const expected = ["latest", "beta"].includes(Installation.CHANNEL)
+      ? path.join(Global.Path.data, "opencode.db")
+      : path.join(Global.Path.data, `opencode-${Installation.CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
+    expect(Database.getChannelPath()).toBe(expected)
   })
 })

+ 187 - 0
packages/opencode/test/sync/index.test.ts

@@ -0,0 +1,187 @@
+import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"
+import { tmpdir } from "../fixture/fixture"
+import z from "zod"
+import { Bus } from "../../src/bus"
+import { Instance } from "../../src/project/instance"
+import { SyncEvent } from "../../src/sync"
+import { Database } from "../../src/storage/db"
+import { EventTable } from "../../src/sync/event.sql"
+import { Identifier } from "../../src/id/id"
+import { Flag } from "../../src/flag/flag"
+import { initProjectors } from "../../src/server/projectors"
+
+const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+
+beforeEach(() => {
+  Database.close()
+
+  // @ts-expect-error don't do this normally, but it works
+  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+})
+
+afterEach(() => {
+  // @ts-expect-error don't do this normally, but it works
+  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
+})
+
+function withInstance(fn: () => void | Promise<void>) {
+  return async () => {
+    await using tmp = await tmpdir()
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await fn()
+      },
+    })
+  }
+}
+
+describe("SyncEvent", () => {
+  function setup() {
+    SyncEvent.reset()
+
+    const Created = SyncEvent.define({
+      type: "item.created",
+      version: 1,
+      aggregate: "id",
+      schema: z.object({ id: z.string(), name: z.string() }),
+    })
+    const Sent = SyncEvent.define({
+      type: "item.sent",
+      version: 1,
+      aggregate: "item_id",
+      schema: z.object({ item_id: z.string(), to: z.string() }),
+    })
+
+    SyncEvent.init({
+      projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})],
+    })
+
+    return { Created, Sent }
+  }
+
+  afterAll(() => {
+    SyncEvent.reset()
+    initProjectors()
+  })
+
+  describe("run", () => {
+    test(
+      "inserts event row",
+      withInstance(() => {
+        const { Created } = setup()
+        SyncEvent.run(Created, { id: "evt_1", name: "first" })
+        const rows = Database.use((db) => db.select().from(EventTable).all())
+        expect(rows).toHaveLength(1)
+        expect(rows[0].type).toBe("item.created.1")
+        expect(rows[0].aggregate_id).toBe("evt_1")
+      }),
+    )
+
+    test(
+      "increments seq per aggregate",
+      withInstance(() => {
+        const { Created } = setup()
+        SyncEvent.run(Created, { id: "evt_1", name: "first" })
+        SyncEvent.run(Created, { id: "evt_1", name: "second" })
+        const rows = Database.use((db) => db.select().from(EventTable).all())
+        expect(rows).toHaveLength(2)
+        expect(rows[1].seq).toBe(rows[0].seq + 1)
+      }),
+    )
+
+    test(
+      "uses custom aggregate field from agg()",
+      withInstance(() => {
+        const { Sent } = setup()
+        SyncEvent.run(Sent, { item_id: "evt_1", to: "james" })
+        const rows = Database.use((db) => db.select().from(EventTable).all())
+        expect(rows).toHaveLength(1)
+        expect(rows[0].aggregate_id).toBe("evt_1")
+      }),
+    )
+
+    test(
+      "emits events",
+      withInstance(async () => {
+        const { Created } = setup()
+        const events: Array<{
+          type: string
+          properties: { id: string; name: string }
+        }> = []
+        const unsub = Bus.subscribeAll((event) => events.push(event))
+
+        SyncEvent.run(Created, { id: "evt_1", name: "test" })
+
+        expect(events).toHaveLength(1)
+        expect(events[0]).toEqual({
+          type: "item.created",
+          properties: {
+            id: "evt_1",
+            name: "test",
+          },
+        })
+
+        unsub()
+      }),
+    )
+  })
+
+  describe("replay", () => {
+    test(
+      "inserts event from external payload",
+      withInstance(() => {
+        const id = Identifier.descending("message")
+        SyncEvent.replay({
+          id: "evt_1",
+          type: "item.created.1",
+          seq: 0,
+          aggregateID: id,
+          data: { id, name: "replayed" },
+        })
+        const rows = Database.use((db) => db.select().from(EventTable).all())
+        expect(rows).toHaveLength(1)
+        expect(rows[0].aggregate_id).toBe(id)
+      }),
+    )
+
+    test(
+      "throws on sequence mismatch",
+      withInstance(() => {
+        const id = Identifier.descending("message")
+        SyncEvent.replay({
+          id: "evt_1",
+          type: "item.created.1",
+          seq: 0,
+          aggregateID: id,
+          data: { id, name: "first" },
+        })
+        expect(() =>
+          SyncEvent.replay({
+            id: "evt_1",
+            type: "item.created.1",
+            seq: 5,
+            aggregateID: id,
+            data: { id, name: "bad" },
+          }),
+        ).toThrow(/Sequence mismatch/)
+      }),
+    )
+
+    test(
+      "throws on unknown event type",
+      withInstance(() => {
+        expect(() =>
+          SyncEvent.replay({
+            id: "evt_1",
+            type: "unknown.event.1",
+            seq: 0,
+            aggregateID: "x",
+            data: {},
+          }),
+        ).toThrow(/Unknown event type/)
+      }),
+    )
+  })
+})

+ 20 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -46,6 +46,7 @@ import type {
   GlobalDisposeResponses,
   GlobalEventResponses,
   GlobalHealthResponses,
+  GlobalSyncEventSubscribeResponses,
   GlobalUpgradeErrors,
   GlobalUpgradeResponses,
   InstanceDisposeResponses,
@@ -230,6 +231,20 @@ class HeyApiRegistry<T> {
   }
 }
 
+export class SyncEvent extends HeyApiClient {
+  /**
+   * Subscribe to global sync events
+   *
+   * Get global sync events
+   */
+  public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).sse.get<GlobalSyncEventSubscribeResponses, unknown, ThrowOnError>({
+      url: "/global/sync-event",
+      ...options,
+    })
+  }
+}
+
 export class Config extends HeyApiClient {
   /**
    * Get global configuration
@@ -329,6 +344,11 @@ export class Global extends HeyApiClient {
     })
   }
 
+  private _syncEvent?: SyncEvent
+  get syncEvent(): SyncEvent {
+    return (this._syncEvent ??= new SyncEvent({ client: this.client }))
+  }
+
   private _config?: Config
   get config(): Config {
     return (this._config ??= new Config({ client: this.client }))

Datei-Diff unterdrückt, da er zu groß ist
+ 634 - 533
packages/sdk/js/src/v2/gen/types.gen.ts


Datei-Diff unterdrückt, da er zu groß ist
+ 910 - 1145
packages/sdk/openapi.json


+ 2 - 1
packages/storybook/.storybook/main.ts

@@ -2,6 +2,7 @@ import { defineMain } from "storybook-solidjs-vite"
 import path from "node:path"
 import { fileURLToPath } from "node:url"
 import tailwindcss from "@tailwindcss/vite"
+import { playgroundCss } from "./playground-css-plugin"
 
 const here = path.dirname(fileURLToPath(import.meta.url))
 const ui = path.resolve(here, "../../ui")
@@ -24,7 +25,7 @@ export default defineMain({
   async viteFinal(config) {
     const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
     return mergeConfig(config, {
-      plugins: [tailwindcss()],
+      plugins: [tailwindcss(), playgroundCss()],
       resolve: {
         dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
         alias: [

+ 136 - 0
packages/storybook/.storybook/playground-css-plugin.ts

@@ -0,0 +1,136 @@
+/**
+ * Vite plugin that exposes a POST endpoint for the timeline playground
+ * to write CSS changes back to source files on disk.
+ *
+ * POST /__playground/apply-css
+ * Body: { edits: Array<{ file: string; anchor: string; prop: string; value: string }> }
+ *
+ * For each edit the plugin finds `anchor` in the file, then locates the
+ * next `prop: <anything>;` after it and replaces the value portion.
+ * `file` is a basename resolved relative to packages/ui/src/components/.
+ */
+import type { Plugin } from "vite"
+import type { IncomingMessage, ServerResponse } from "node:http"
+import fs from "node:fs"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const root = path.resolve(here, "../../ui/src/components")
+
+const ENDPOINT = "/__playground/apply-css"
+
+type Edit = { file: string; anchor: string; prop: string; value: string }
+type Result = { file: string; prop: string; ok: boolean; error?: string }
+
+function applyEdits(content: string, edits: Edit[]): { content: string; results: Result[] } {
+  const results: Result[] = []
+  let out = content
+
+  for (const edit of edits) {
+    const name = edit.file
+    const idx = out.indexOf(edit.anchor)
+    if (idx === -1) {
+      results.push({ file: name, prop: edit.prop, ok: false, error: `Anchor not found: ${edit.anchor.slice(0, 50)}` })
+      continue
+    }
+
+    // From the anchor position, find the next occurrence of `prop: <value>`
+    // We match `prop:` followed by any value up to `;`
+    const after = out.slice(idx)
+    const re = new RegExp(`(${escapeRegex(edit.prop)}\\s*:\\s*)([^;]+)(;)`)
+    const match = re.exec(after)
+    if (!match) {
+      results.push({ file: name, prop: edit.prop, ok: false, error: `Property "${edit.prop}" not found after anchor` })
+      continue
+    }
+
+    const start = idx + match.index + match[1].length
+    const end = start + match[2].length
+    out = out.slice(0, start) + edit.value + out.slice(end)
+    results.push({ file: name, prop: edit.prop, ok: true })
+  }
+
+  return { content: out, results }
+}
+
+function escapeRegex(s: string) {
+  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+export function playgroundCss(): Plugin {
+  return {
+    name: "playground-css",
+    configureServer(server) {
+      server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
+        if (req.url !== ENDPOINT) return next()
+        if (req.method !== "POST") {
+          res.statusCode = 405
+          res.setHeader("Content-Type", "application/json")
+          res.end(JSON.stringify({ error: "Method not allowed" }))
+          return
+        }
+
+        let data = ""
+        req.on("data", (chunk: Buffer) => {
+          data += chunk.toString()
+        })
+        req.on("end", () => {
+          let payload: { edits: Edit[] }
+          try {
+            payload = JSON.parse(data)
+          } catch {
+            res.statusCode = 400
+            res.setHeader("Content-Type", "application/json")
+            res.end(JSON.stringify({ error: "Invalid JSON" }))
+            return
+          }
+
+          if (!Array.isArray(payload.edits)) {
+            res.statusCode = 400
+            res.setHeader("Content-Type", "application/json")
+            res.end(JSON.stringify({ error: "Missing edits array" }))
+            return
+          }
+
+          // Group by file
+          const grouped = new Map<string, Edit[]>()
+          for (const edit of payload.edits) {
+            if (!edit.file || !edit.anchor || !edit.prop || edit.value === undefined) continue
+            const abs = path.resolve(root, edit.file)
+            if (!abs.startsWith(root)) continue
+            const key = abs
+            if (!grouped.has(key)) grouped.set(key, [])
+            grouped.get(key)!.push(edit)
+          }
+
+          const results: Result[] = []
+
+          for (const [abs, edits] of grouped) {
+            const name = path.basename(abs)
+            if (!fs.existsSync(abs)) {
+              for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: "File not found" })
+              continue
+            }
+
+            try {
+              const content = fs.readFileSync(abs, "utf-8")
+              const applied = applyEdits(content, edits)
+              results.push(...applied.results)
+
+              if (applied.results.some((r) => r.ok)) {
+                fs.writeFileSync(abs, applied.content, "utf-8")
+              }
+            } catch (err) {
+              for (const e of edits) results.push({ file: name, prop: e.prop, ok: false, error: String(err) })
+            }
+          }
+
+          res.statusCode = 200
+          res.setHeader("Content-Type", "application/json")
+          res.end(JSON.stringify({ results }))
+        })
+      })
+    },
+  }
+}

+ 0 - 5
packages/ui/src/components/message-part.css

@@ -248,11 +248,6 @@
     opacity: 1;
     pointer-events: auto;
   }
-
-  [data-component="markdown"] {
-    margin-top: 0;
-    font-size: var(--font-size-base);
-  }
 }
 
 [data-component="compaction-part"] {

+ 4 - 4
packages/ui/src/components/session-turn.css

@@ -85,10 +85,6 @@
     flex-direction: column;
     align-self: stretch;
     gap: 12px;
-
-    > :first-child > [data-component="markdown"]:first-child {
-      margin-top: 0;
-    }
   }
 
   [data-slot="session-turn-diffs"] {
@@ -230,3 +226,7 @@
     display: none;
   }
 }
+
+[data-slot="session-turn-list"] {
+  gap: 48px;
+}

+ 1771 - 0
packages/ui/src/components/timeline-playground.stories.tsx

@@ -0,0 +1,1771 @@
+// @ts-nocheck
+import { createSignal, createMemo, createEffect, on, For, Show, Index, batch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import type {
+  Message,
+  UserMessage,
+  AssistantMessage,
+  Part,
+  TextPart,
+  ReasoningPart,
+  ToolPart,
+  CompactionPart,
+  FilePart,
+  AgentPart,
+} from "@opencode-ai/sdk/v2"
+import { DataProvider } from "../context/data"
+import { FileComponentProvider } from "../context/file"
+import { SessionTurn } from "./session-turn"
+
+// ---------------------------------------------------------------------------
+// ID helpers
+// ---------------------------------------------------------------------------
+let seq = 0
+const uid = () => `pg-${++seq}-${Date.now().toString(36)}`
+
+// ---------------------------------------------------------------------------
+// Lorem ipsum content
+// ---------------------------------------------------------------------------
+const LOREM = [
+  "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+  "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+  "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
+  "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
+  "Cras justo odio, dapibus ut facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper.",
+]
+
+// ---------------------------------------------------------------------------
+// User message variants
+// ---------------------------------------------------------------------------
+const USER_VARIANTS = {
+  short: {
+    label: "short",
+    text: "Fix the bug in the login form",
+    parts: [] as Part[],
+  },
+  medium: {
+    label: "medium",
+    text: "Can you update the session timeline component to support lazy loading? The current implementation loads everything eagerly which causes jank on large sessions.",
+    parts: [] as Part[],
+  },
+  long: {
+    label: "long",
+    text: `I need you to refactor the message rendering pipeline. Currently the timeline renders all messages synchronously which blocks first paint. Here's what I want:
+
+1. Implement virtual scrolling for the message list
+2. Defer-mount older messages using requestAnimationFrame batching
+3. Add content-visibility: auto to each turn container
+4. Make sure the scroll-to-bottom behavior still works correctly after these changes
+
+Please also add appropriate CSS containment hints and make sure we don't break the sticky header behavior for the session title.`,
+    parts: [] as Part[],
+  },
+  "with @file": {
+    label: "with @file",
+    text: "Update @src/components/session-turn.tsx to fix the spacing issue between parts",
+    parts: (() => {
+      const id = `static-file-${Date.now()}`
+      return [
+        {
+          id,
+          type: "file",
+          mime: "text/plain",
+          filename: "session-turn.tsx",
+          url: "src/components/session-turn.tsx",
+          source: {
+            type: "file",
+            path: "src/components/session-turn.tsx",
+            text: {
+              value: "@src/components/session-turn.tsx",
+              start: 7,
+              end: 38,
+            },
+          },
+        } as FilePart,
+      ]
+    })(),
+  },
+  "with @agent": {
+    label: "with @agent",
+    text: "Use @explore to find all CSS files related to the timeline, then fix the spacing",
+    parts: (() => {
+      return [
+        {
+          id: `static-agent-${Date.now()}`,
+          type: "agent",
+          name: "explore",
+          source: { start: 4, end: 12 },
+        } as AgentPart,
+      ]
+    })(),
+  },
+  "with image": {
+    label: "with image",
+    text: "Here's a screenshot of the bug I'm seeing",
+    parts: (() => {
+      // 1x1 blue pixel PNG as data URI for a realistic attachment
+      const pixel =
+        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+      return [
+        {
+          id: `static-img-${Date.now()}`,
+          type: "file",
+          mime: "image/png",
+          filename: "screenshot.png",
+          url: pixel,
+        } as FilePart,
+      ]
+    })(),
+  },
+  "with file attachment": {
+    label: "with file attachment",
+    text: "Check this config file for issues",
+    parts: (() => {
+      return [
+        {
+          id: `static-attach-${Date.now()}`,
+          type: "file",
+          mime: "application/json",
+          filename: "tsconfig.json",
+          url: "data:application/json;base64,e30=",
+        } as FilePart,
+      ]
+    })(),
+  },
+  "multi attachment": {
+    label: "multi attachment",
+    text: "Look at these files and the screenshot, then fix the layout",
+    parts: (() => {
+      const pixel =
+        "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+      return [
+        {
+          id: `static-multi-img-${Date.now()}`,
+          type: "file",
+          mime: "image/png",
+          filename: "layout-bug.png",
+          url: pixel,
+        } as FilePart,
+        {
+          id: `static-multi-file-${Date.now()}`,
+          type: "file",
+          mime: "text/css",
+          filename: "session-turn.css",
+          url: "data:text/css;base64,LyogZW1wdHkgKi8=",
+        } as FilePart,
+        {
+          id: `static-multi-ref-${Date.now()}`,
+          type: "file",
+          mime: "text/plain",
+          filename: "session-turn.tsx",
+          url: "src/components/session-turn.tsx",
+          source: {
+            type: "file",
+            path: "src/components/session-turn.tsx",
+            text: { value: "@src/components/session-turn.tsx", start: 0, end: 0 },
+          },
+        } as FilePart,
+      ]
+    })(),
+  },
+} satisfies Record<string, { label: string; text: string; parts: Part[] }>
+
+const MARKDOWN_SAMPLES = {
+  headings: `# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+
+Some paragraph text after headings.`,
+
+  lists: `Here's a list of changes:
+
+- First item with some explanation
+- Second item that is a bit longer and wraps to the next line when the viewport is narrow
+- Third item
+  - Nested item A
+  - Nested item B
+
+1. Numbered first
+2. Numbered second
+3. Numbered third`,
+
+  code: `Here's an inline \`variable\` reference and a code block:
+
+\`\`\`typescript
+export function sum(values: number[]) {
+  return values.reduce((total, value) => total + value, 0)
+}
+
+export function average(values: number[]) {
+  if (values.length === 0) return 0
+  return sum(values) / values.length
+}
+\`\`\`
+
+And some text after the code block.`,
+
+  mixed: `## Implementation Plan
+
+I'll make the following changes:
+
+1. **Update the schema** - Add new fields to the database model
+2. **Create the API endpoint** - Handle validation and persistence
+3. **Add frontend components** - Build the form and display views
+
+Here's the key change:
+
+\`\`\`typescript
+const table = sqliteTable("session", {
+  id: text().primaryKey(),
+  project_id: text().notNull(),
+  created_at: integer().notNull(),
+})
+\`\`\`
+
+> Note: This is a breaking change that requires a migration.
+
+The migration will handle existing data by setting \`project_id\` to the default workspace.
+
+---
+
+For more details, see the [documentation](https://example.com/docs).`,
+
+  table: `## Comparison
+
+| Feature | Before | After |
+|---------|--------|-------|
+| Speed | 120ms | 45ms |
+| Memory | 256MB | 128MB |
+| Bundle | 1.2MB | 890KB |
+
+The improvements are significant across all metrics.`,
+
+  blockquote: `## Summary
+
+> This is a blockquote that contains important information about the implementation approach.
+>
+> It spans multiple lines and contains **bold** and \`code\` elements.
+
+The approach above was chosen for its simplicity.`,
+
+  links: `Check out these resources:
+
+- [SolidJS docs](https://solidjs.com)
+- [TypeScript handbook](https://www.typescriptlang.org/docs/handbook)
+- The API is at \`https://api.example.com/v2\`
+
+You can also visit https://example.com/docs for more info.`,
+
+  images: `## Screenshot
+
+Here's what the output looks like:
+
+![Alt text](https://via.placeholder.com/400x200)
+
+And below is the final result.`,
+}
+
+const REASONING_SAMPLES = [
+  `**Analyzing the request**
+
+The user wants to add a new feature to the session timeline. I need to understand the existing component structure first.
+
+Let me look at the key files involved:
+- \`session-turn.tsx\` handles individual turns
+- \`message-part.tsx\` renders different part types
+- The data flows through the \`DataProvider\` context`,
+
+  `**Considering approaches**
+
+I could either modify the existing SessionTurn component or create a wrapper. The wrapper approach is cleaner because it doesn't touch the core rendering logic.
+
+The trade-off is that we'd need to pass additional props through, but that's acceptable for this use case.`,
+
+  `**Planning the implementation**
+
+I'll need to:
+1. Create the data generators
+2. Wire up the context providers
+3. Add CSS variable controls
+4. Implement the export functionality
+
+This should be straightforward given the existing component architecture.`,
+]
+
+const TOOL_SAMPLES = {
+  read: {
+    tool: "read",
+    input: { filePath: "src/components/session-turn.tsx", offset: 1, limit: 50 },
+    output: "export function SessionTurn(props) {\n  // component implementation\n  return <div>...</div>\n}",
+    title: "Read src/components/session-turn.tsx",
+    metadata: {},
+  },
+  glob: {
+    tool: "glob",
+    input: { pattern: "**/*.tsx", path: "src/components" },
+    output: "src/components/button.tsx\nsrc/components/card.tsx\nsrc/components/session-turn.tsx",
+    title: "Found 3 files",
+    metadata: {},
+  },
+  grep: {
+    tool: "grep",
+    input: { pattern: "SessionTurn", path: "src", include: "*.tsx" },
+    output: "src/components/session-turn.tsx:141\nsrc/pages/session/timeline.tsx:987",
+    title: "Found 2 matches",
+    metadata: {},
+  },
+  bash: {
+    tool: "bash",
+    input: { command: "bun test --filter session", description: "Run session tests" },
+    output:
+      "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests:       10 passed, 10 total\nTime:        0.89s",
+    title: "Run session tests",
+    metadata: { command: "bun test --filter session" },
+  },
+  edit: {
+    tool: "edit",
+    input: {
+      filePath: "src/components/session-turn.tsx",
+      oldString: "gap: 12px",
+      newString: "gap: 18px",
+    },
+    output: "File edited successfully",
+    title: "Edit src/components/session-turn.tsx",
+    metadata: {
+      filediff: {
+        file: "src/components/session-turn.tsx",
+        before: "  gap: 12px;\n  display: flex;",
+        after: "  gap: 18px;\n  display: flex;",
+        additions: 1,
+        deletions: 1,
+      },
+    },
+  },
+  write: {
+    tool: "write",
+    input: {
+      filePath: "src/utils/helpers.ts",
+      content:
+        "export function clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n",
+    },
+    output: "File written successfully",
+    title: "Write src/utils/helpers.ts",
+    metadata: {},
+  },
+  task: {
+    tool: "task",
+    input: { description: "Explore components", subagent_type: "explore", prompt: "Find all session components" },
+    output: "Found 12 session-related components across 3 directories.",
+    title: "Agent (Explore)",
+    metadata: { sessionId: "sub-session-1" },
+  },
+  webfetch: {
+    tool: "webfetch",
+    input: { url: "https://solidjs.com/docs/latest/api" },
+    output: "# SolidJS API Reference\n\nCore primitives for building reactive applications...",
+    title: "Fetch https://solidjs.com/docs/latest/api",
+    metadata: {},
+  },
+  websearch: {
+    tool: "websearch",
+    input: { query: "SolidJS createStore performance" },
+    output:
+      "https://solidjs.com/docs/latest/api#createstore\nhttps://dev.to/solidjs/understanding-solid-reactivity\nhttps://github.com/solidjs/solid/discussions/1234",
+    title: "Search: SolidJS createStore performance",
+    metadata: {},
+  },
+  question: {
+    tool: "question",
+    input: {
+      questions: [
+        {
+          question: "Which approach do you prefer?",
+          header: "Approach",
+          options: [
+            { label: "Wrapper component", description: "Create a new wrapper around SessionTurn" },
+            { label: "Direct modification", description: "Modify SessionTurn directly" },
+          ],
+        },
+      ],
+    },
+    output: "",
+    title: "Question",
+    metadata: { answers: [["Wrapper component"]] },
+  },
+  skill: {
+    tool: "skill",
+    input: { name: "playwriter" },
+    output: "Skill loaded successfully",
+    title: "playwriter",
+    metadata: {},
+  },
+  todowrite: {
+    tool: "todowrite",
+    input: {
+      todos: [
+        { content: "Create data generators", status: "completed", priority: "high" },
+        { content: "Build UI controls", status: "in_progress", priority: "high" },
+        { content: "Add CSS export", status: "pending", priority: "medium" },
+      ],
+    },
+    output: "",
+    title: "Todos",
+    metadata: {
+      todos: [
+        { content: "Create data generators", status: "completed", priority: "high" },
+        { content: "Build UI controls", status: "in_progress", priority: "high" },
+        { content: "Add CSS export", status: "pending", priority: "medium" },
+      ],
+    },
+  },
+}
+
+// ---------------------------------------------------------------------------
+// Fake data generators
+// ---------------------------------------------------------------------------
+const SESSION_ID = "playground-session"
+
+function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } {
+  const id = uid()
+  return {
+    message: {
+      id,
+      sessionID: SESSION_ID,
+      role: "user",
+      time: { created: Date.now() },
+      agent: "code",
+      model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+    } as UserMessage,
+    parts: [
+      { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart,
+      // Clone extra parts with fresh ids so each user message owns unique part instances
+      ...extra.map((p) => ({ ...p, id: uid() })),
+    ],
+  }
+}
+
+function mkAssistant(parentID: string): AssistantMessage {
+  return {
+    id: uid(),
+    sessionID: SESSION_ID,
+    role: "assistant",
+    time: { created: Date.now(), completed: Date.now() + 3000 },
+    parentID,
+    modelID: "claude-sonnet-4-20250514",
+    providerID: "anthropic",
+    mode: "default",
+    agent: "code",
+    path: { cwd: "/project", root: "/project" },
+    cost: 0.003,
+    tokens: { input: 1200, output: 800, reasoning: 200, cache: { read: 0, write: 0 } },
+  } as AssistantMessage
+}
+
+function textPart(text: string): TextPart {
+  return { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart
+}
+
+function reasoningPart(text: string): ReasoningPart {
+  return { id: uid(), type: "reasoning", text, time: { start: Date.now(), end: Date.now() + 500 } } as ReasoningPart
+}
+
+function toolPart(sample: (typeof TOOL_SAMPLES)[keyof typeof TOOL_SAMPLES], status = "completed"): ToolPart {
+  const base = {
+    id: uid(),
+    type: "tool" as const,
+    callID: uid(),
+    tool: sample.tool,
+  }
+  if (status === "completed") {
+    return {
+      ...base,
+      state: {
+        status: "completed",
+        input: sample.input,
+        output: sample.output,
+        title: sample.title,
+        metadata: sample.metadata ?? {},
+        time: { start: Date.now(), end: Date.now() + 1000 },
+      },
+    } as ToolPart
+  }
+  if (status === "running") {
+    return {
+      ...base,
+      state: {
+        status: "running",
+        input: sample.input,
+        title: sample.title,
+        metadata: sample.metadata ?? {},
+        time: { start: Date.now() },
+      },
+    } as ToolPart
+  }
+  return {
+    ...base,
+    state: { status: "pending", input: sample.input, raw: "" },
+  } as ToolPart
+}
+
+function compactionPart(): CompactionPart {
+  return { id: uid(), type: "compaction", auto: true } as CompactionPart
+}
+
+// ---------------------------------------------------------------------------
+// CSS Controls definition
+// ---------------------------------------------------------------------------
+
+// Source file basenames inside packages/ui/src/components/
+const MD = "markdown.css"
+const MP = "message-part.css"
+const ST = "session-turn.css"
+
+/**
+ * Source mapping for a CSS control.
+ * - `anchor`: immutable text near the property (comment, selector, etc.) that
+ *   won't change when values change — used to locate the right rule block.
+ * - `prop`: the CSS property name whose value gets replaced.
+ * - `format`: turns the slider number into a CSS value string.
+ */
+type CSSSource = {
+  file: string
+  anchor: string
+  prop: string
+  format: (v: string) => string
+}
+
+type CSSControl = {
+  key: string
+  label: string
+  group: string
+  type: "range" | "color" | "select"
+  initial: string
+  selector: string
+  property: string
+  min?: string
+  max?: string
+  step?: string
+  options?: string[]
+  unit?: string
+  source?: CSSSource
+}
+
+const px = (v: string) => `${v}px`
+const pxZero = (v: string) => `${v}px 0`
+const pct = (v: string) => `${v}%`
+
+const CSS_CONTROLS: CSSControl[] = [
+  // --- Timeline spacing ---
+  {
+    key: "turn-gap",
+    label: "Turn gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "48",
+    selector: '[data-slot="session-turn-list"]',
+    property: "gap",
+    min: "0",
+    max: "80",
+    step: "1",
+    unit: "px",
+    source: { file: ST, anchor: '[data-slot="session-turn-list"]', prop: "gap", format: px },
+  },
+  {
+    key: "container-gap",
+    label: "Container gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "18",
+    selector: '[data-slot="session-turn-message-container"]',
+    property: "gap",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: ST, anchor: '[data-slot="session-turn-message-container"]', prop: "gap", format: px },
+  },
+  {
+    key: "assistant-gap",
+    label: "Assistant parts gap",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "12",
+    selector: '[data-slot="session-turn-assistant-content"]',
+    property: "gap",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: ST, anchor: '[data-slot="session-turn-assistant-content"]', prop: "gap", format: px },
+  },
+  {
+    key: "text-part-margin",
+    label: "Text part margin-top",
+    group: "Timeline Spacing",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="text-part"]',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MP, anchor: '[data-component="text-part"]', prop: "margin-top", format: px },
+  },
+
+  // --- Markdown typography ---
+  {
+    key: "md-font-size",
+    label: "Font size",
+    group: "Markdown Typography",
+    type: "range",
+    initial: "14",
+    selector: '[data-component="markdown"]',
+    property: "font-size",
+    min: "10",
+    max: "22",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "font-size", format: px },
+  },
+  {
+    key: "md-line-height",
+    label: "Line height",
+    group: "Markdown Typography",
+    type: "range",
+    initial: "180",
+    selector: '[data-component="markdown"]',
+    property: "line-height",
+    min: "100",
+    max: "300",
+    step: "5",
+    unit: "%",
+    source: { file: MD, anchor: "/* Reset & Base Typography */", prop: "line-height", format: pct },
+  },
+
+  // --- Markdown headings ---
+  {
+    key: "md-heading-margin-top",
+    label: "Heading margin-top",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Headings:", prop: "margin-top", format: px },
+  },
+  {
+    key: "md-heading-margin-bottom",
+    label: "Heading margin-bottom",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Headings:", prop: "margin-bottom", format: px },
+  },
+  {
+    key: "md-heading-font-size",
+    label: "Heading font size",
+    group: "Markdown Headings",
+    type: "range",
+    initial: "14",
+    selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)',
+    property: "font-size",
+    min: "12",
+    max: "28",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Headings:", prop: "font-size", format: px },
+  },
+
+  // --- Markdown paragraphs ---
+  {
+    key: "md-p-margin-bottom",
+    label: "Paragraph margin-bottom",
+    group: "Markdown Paragraphs",
+    type: "range",
+    initial: "16",
+    selector: '[data-component="markdown"] p',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Paragraphs */", prop: "margin-bottom", format: px },
+  },
+
+  // --- Markdown lists ---
+  {
+    key: "md-list-margin-top",
+    label: "List margin-top",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "margin-top",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Lists */", prop: "margin-top", format: px },
+  },
+  {
+    key: "md-list-margin-bottom",
+    label: "List margin-bottom",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "16",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "margin-bottom",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Lists */", prop: "margin-bottom", format: px },
+  },
+  {
+    key: "md-list-padding-left",
+    label: "List padding-left",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] :is(ul,ol)',
+    property: "padding-left",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Lists */", prop: "padding-left", format: px },
+  },
+  {
+    key: "md-li-margin-bottom",
+    label: "List item margin-bottom",
+    group: "Markdown Lists",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] li',
+    property: "margin-bottom",
+    min: "0",
+    max: "20",
+    step: "1",
+    unit: "px",
+    // Anchor on `li {` to skip the `ul,ol` margin-bottom above
+    source: { file: MD, anchor: "\n  li {", prop: "margin-bottom", format: px },
+  },
+
+  // --- Markdown code blocks ---
+  {
+    key: "md-pre-margin-top",
+    label: "Code block margin-top",
+    group: "Markdown Code",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] pre',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "\n  pre {", prop: "margin-top", format: px },
+  },
+  {
+    key: "md-pre-margin-bottom",
+    label: "Code block margin-bottom",
+    group: "Markdown Code",
+    type: "range",
+    initial: "32",
+    selector: '[data-component="markdown"] pre',
+    property: "margin-bottom",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "\n  pre {", prop: "margin-bottom", format: px },
+  },
+  {
+    key: "md-shiki-font-size",
+    label: "Code font size",
+    group: "Markdown Code",
+    type: "range",
+    initial: "13",
+    selector: '[data-component="markdown"] .shiki',
+    property: "font-size",
+    min: "10",
+    max: "20",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: ".shiki {", prop: "font-size", format: px },
+  },
+  {
+    key: "md-shiki-padding",
+    label: "Code padding",
+    group: "Markdown Code",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] .shiki',
+    property: "padding",
+    min: "0",
+    max: "32",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: ".shiki {", prop: "padding", format: px },
+  },
+  {
+    key: "md-shiki-radius",
+    label: "Code border-radius",
+    group: "Markdown Code",
+    type: "range",
+    initial: "6",
+    selector: '[data-component="markdown"] .shiki',
+    property: "border-radius",
+    min: "0",
+    max: "16",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: ".shiki {", prop: "border-radius", format: px },
+  },
+
+  // --- Markdown blockquotes ---
+  {
+    key: "md-blockquote-margin",
+    label: "Blockquote margin",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] blockquote',
+    property: "margin-block",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Blockquotes */", prop: "margin", format: pxZero },
+  },
+  {
+    key: "md-blockquote-padding-left",
+    label: "Blockquote padding-left",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "8",
+    selector: '[data-component="markdown"] blockquote',
+    property: "padding-left",
+    min: "0",
+    max: "40",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Blockquotes */", prop: "padding-left", format: px },
+  },
+  {
+    key: "md-blockquote-border-width",
+    label: "Blockquote border width",
+    group: "Markdown Blockquotes",
+    type: "range",
+    initial: "2",
+    selector: '[data-component="markdown"] blockquote',
+    property: "border-left-width",
+    min: "0",
+    max: "8",
+    step: "1",
+    unit: "px",
+    source: {
+      file: MD,
+      anchor: "/* Blockquotes */",
+      prop: "border-left",
+      format: (v) => `${v}px solid var(--border-weak-base)`,
+    },
+  },
+
+  // --- Markdown tables ---
+  {
+    key: "md-table-margin",
+    label: "Table margin",
+    group: "Markdown Tables",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="markdown"] table',
+    property: "margin-block",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Tables */", prop: "margin", format: pxZero },
+  },
+  {
+    key: "md-td-padding",
+    label: "Cell padding",
+    group: "Markdown Tables",
+    type: "range",
+    initial: "12",
+    selector: '[data-component="markdown"] :is(th,td)',
+    property: "padding",
+    min: "0",
+    max: "24",
+    step: "1",
+    unit: "px",
+    // Anchor on td selector to skip other padding rules
+    source: { file: MD, anchor: "th,\n  td {", prop: "padding", format: px },
+  },
+
+  // --- Markdown HR ---
+  {
+    key: "md-hr-margin",
+    label: "HR margin",
+    group: "Markdown HR",
+    type: "range",
+    initial: "40",
+    selector: '[data-component="markdown"] hr',
+    property: "margin-block",
+    min: "0",
+    max: "80",
+    step: "1",
+    unit: "px",
+    source: { file: MD, anchor: "/* Horizontal Rule", prop: "margin", format: pxZero },
+  },
+
+  // --- Reasoning part ---
+  {
+    key: "reasoning-md-margin-top",
+    label: "Reasoning markdown margin-top",
+    group: "Reasoning Part",
+    type: "range",
+    initial: "24",
+    selector: '[data-component="reasoning-part"] [data-component="markdown"]',
+    property: "margin-top",
+    min: "0",
+    max: "60",
+    step: "1",
+    unit: "px",
+    source: { file: MP, anchor: '[data-component="reasoning-part"]', prop: "margin-top", format: px },
+  },
+
+  // --- User message ---
+  {
+    key: "user-msg-padding",
+    label: "User bubble padding",
+    group: "User Message",
+    type: "range",
+    initial: "12",
+    selector: '[data-slot="user-message-text"]',
+    property: "padding",
+    min: "0",
+    max: "32",
+    step: "1",
+    unit: "px",
+    source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "padding", format: px },
+  },
+  {
+    key: "user-msg-radius",
+    label: "User bubble border-radius",
+    group: "User Message",
+    type: "range",
+    initial: "6",
+    selector: '[data-slot="user-message-text"]',
+    property: "border-radius",
+    min: "0",
+    max: "24",
+    step: "1",
+    unit: "px",
+    source: { file: MP, anchor: '[data-slot="user-message-text"]', prop: "border-radius", format: px },
+  },
+
+  // --- Tool parts ---
+  {
+    key: "bash-max-height",
+    label: "Shell output max-height",
+    group: "Tool Parts",
+    type: "range",
+    initial: "240",
+    selector: '[data-slot="bash-scroll"]',
+    property: "max-height",
+    min: "100",
+    max: "600",
+    step: "10",
+    unit: "px",
+    source: { file: MP, anchor: '[data-slot="bash-scroll"]', prop: "max-height", format: px },
+  },
+]
+
+// ---------------------------------------------------------------------------
+// Playground component
+// ---------------------------------------------------------------------------
+function FileStub() {
+  return <div style={{ padding: "8px", color: "var(--text-weak)", "font-size": "13px" }}>File viewer stub</div>
+}
+
+function Playground() {
+  // ---- Messages & parts state ----
+  const [state, setState] = createStore<{
+    messages: Message[]
+    parts: Record<string, Part[]>
+  }>({
+    messages: [],
+    parts: {},
+  })
+
+  // ---- CSS overrides ----
+  const [css, setCss] = createStore<Record<string, string>>({})
+  const [defaults, setDefaults] = createStore<Record<string, string>>({})
+  let styleEl: HTMLStyleElement | undefined
+  let previewRef: HTMLDivElement | undefined
+
+  /** Read computed styles from the DOM to seed slider defaults */
+  const readDefaults = () => {
+    const root = previewRef
+    if (!root) return
+    const next: Record<string, string> = {}
+    for (const ctrl of CSS_CONTROLS) {
+      const el = root.querySelector(ctrl.selector) as HTMLElement | null
+      if (!el) continue
+      const styles = getComputedStyle(el)
+      // Use bracket access — getPropertyValue doesn't resolve shorthands
+      const raw = (styles as any)[ctrl.property] as string
+      if (!raw) continue
+      // Shorthands may return "24px 0px" — take the first value
+      const num = parseFloat(raw.split(" ")[0])
+      if (!Number.isFinite(num)) continue
+      // line-height returns px — convert back to % relative to font-size
+      if (ctrl.unit === "%") {
+        const fs = parseFloat(styles.fontSize)
+        if (fs > 0) {
+          next[ctrl.key] = String(Math.round((num / fs) * 100))
+          continue
+        }
+      }
+      next[ctrl.key] = String(Math.round(num))
+    }
+    setDefaults(next)
+  }
+
+  const updateStyle = () => {
+    const rules: string[] = []
+    for (const ctrl of CSS_CONTROLS) {
+      const val = css[ctrl.key]
+      if (val === undefined) continue
+      const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+      rules.push(`${ctrl.selector} { ${ctrl.property}: ${value} !important; }`)
+    }
+    if (styleEl) styleEl.textContent = rules.join("\n")
+  }
+
+  const setCssValue = (key: string, value: string) => {
+    setCss(key, value)
+    updateStyle()
+  }
+
+  const resetCss = () => {
+    batch(() => {
+      for (const ctrl of CSS_CONTROLS) {
+        setCss(ctrl.key, undefined as any)
+      }
+    })
+    if (styleEl) styleEl.textContent = ""
+  }
+
+  // ---- Derived ----
+  const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
+
+  const data = createMemo(() => ({
+    session: [{ id: SESSION_ID }],
+    session_status: {},
+    session_diff: {},
+    message: { [SESSION_ID]: state.messages },
+    part: state.parts,
+    provider: {
+      all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
+    },
+  }))
+
+  // Read computed defaults once DOM has turn elements to query
+  createEffect(
+    on(
+      () => userMessages().length,
+      (len) => {
+        if (len === 0) return
+        // Wait a frame for the DOM to settle after render
+        requestAnimationFrame(readDefaults)
+      },
+    ),
+  )
+
+  // ---- Find or create the last assistant message to append parts to ----
+  const lastAssistantID = createMemo(() => {
+    for (let i = state.messages.length - 1; i >= 0; i--) {
+      if (state.messages[i].role === "assistant") return state.messages[i].id
+    }
+    return undefined
+  })
+
+  /** Ensure a turn (user + assistant) exists and return the assistant message id */
+  const ensureTurn = (): string => {
+    const id = lastAssistantID()
+    if (id) return id
+    // Create a minimal placeholder turn
+    const user = mkUser("...")
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = []
+      }),
+    )
+    return asst.id
+  }
+
+  /** Append parts to the last assistant message */
+  const appendParts = (parts: Part[]) => {
+    const id = ensureTurn()
+    setState(
+      produce((draft) => {
+        const existing = draft.parts[id] ?? []
+        draft.parts[id] = [...existing, ...parts]
+      }),
+    )
+  }
+
+  // ---- User message helpers ----
+  const addUser = (variant: keyof typeof USER_VARIANTS) => {
+    const v = USER_VARIANTS[variant]
+    const user = mkUser(v.text, v.parts)
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = []
+      }),
+    )
+  }
+
+  // ---- Part helpers (append to last turn) ----
+  const addText = (variant: keyof typeof MARKDOWN_SAMPLES) => {
+    appendParts([textPart(MARKDOWN_SAMPLES[variant])])
+  }
+
+  const addReasoning = () => {
+    const idx = Math.floor(Math.random() * REASONING_SAMPLES.length)
+    appendParts([reasoningPart(REASONING_SAMPLES[idx])])
+  }
+
+  const addTool = (name: keyof typeof TOOL_SAMPLES) => {
+    appendParts([toolPart(TOOL_SAMPLES[name])])
+  }
+
+  // ---- Composite helpers (create full turns with user + assistant) ----
+  const addFullTurn = (userText: string, parts: Part[]) => {
+    const user = mkUser(userText)
+    const asst = mkAssistant(user.message.id)
+    setState(
+      produce((draft) => {
+        draft.messages.push(user.message)
+        draft.messages.push(asst)
+        draft.parts[user.message.id] = user.parts
+        draft.parts[asst.id] = parts
+      }),
+    )
+  }
+
+  const addContextGroupTurn = () => {
+    addFullTurn("Read some files", [
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      textPart("After gathering context, here's what I found:\n\n" + LOREM[2]),
+    ])
+  }
+
+  const addReasoningFullTurn = () => {
+    addFullTurn("Make the changes described above", [
+      reasoningPart(REASONING_SAMPLES[0]),
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      toolPart(TOOL_SAMPLES.edit),
+      toolPart(TOOL_SAMPLES.bash),
+      textPart(MARKDOWN_SAMPLES.mixed),
+    ])
+  }
+
+  const addKitchenSink = () => {
+    // User message variants
+    addUser("short")
+    appendParts([textPart(MARKDOWN_SAMPLES.headings)])
+    addUser("medium")
+    appendParts([textPart(MARKDOWN_SAMPLES.lists)])
+    addUser("long")
+    appendParts([textPart(MARKDOWN_SAMPLES.code)])
+    addUser("with @file")
+    appendParts([textPart(MARKDOWN_SAMPLES.mixed)])
+    addUser("with image")
+    appendParts([reasoningPart(REASONING_SAMPLES[0]), textPart(MARKDOWN_SAMPLES.table)])
+    addUser("multi attachment")
+    appendParts([
+      toolPart(TOOL_SAMPLES.read),
+      toolPart(TOOL_SAMPLES.glob),
+      toolPart(TOOL_SAMPLES.grep),
+      toolPart(TOOL_SAMPLES.edit),
+      toolPart(TOOL_SAMPLES.bash),
+      textPart(MARKDOWN_SAMPLES.blockquote),
+    ])
+    addContextGroupTurn()
+    addReasoningFullTurn()
+  }
+
+  const clearAll = () => {
+    setState({ messages: [], parts: {} })
+    seq = 0
+  }
+
+  // ---- CSS export ----
+  const exportCss = () => {
+    const lines: string[] = ["/* Timeline Playground CSS Overrides */", ""]
+    const groups = new Map<string, string[]>()
+
+    for (const ctrl of CSS_CONTROLS) {
+      const val = css[ctrl.key]
+      if (val === undefined) continue
+      const value = ctrl.unit ? `${val}${ctrl.unit}` : val
+      const group = ctrl.group
+      if (!groups.has(group)) groups.set(group, [])
+      groups.get(group)!.push(`/* ${ctrl.label}: ${value} */`)
+      groups.get(group)!.push(`${ctrl.selector} { ${ctrl.property}: ${value}; }`)
+    }
+
+    if (groups.size === 0) {
+      lines.push("/* No overrides applied */")
+    } else {
+      for (const [group, rules] of groups) {
+        lines.push(`/* --- ${group} --- */`)
+        lines.push(...rules)
+        lines.push("")
+      }
+    }
+
+    const text = lines.join("\n")
+    navigator.clipboard.writeText(text).catch(() => {})
+    return text
+  }
+
+  const [exported, setExported] = createSignal("")
+
+  // ---- Apply to source files ----
+  const [applying, setApplying] = createSignal(false)
+  const [applyResult, setApplyResult] = createSignal("")
+
+  const changedControls = createMemo(() => CSS_CONTROLS.filter((ctrl) => css[ctrl.key] !== undefined && ctrl.source))
+
+  const applyToSource = async () => {
+    const controls = changedControls()
+    if (controls.length === 0) return
+
+    setApplying(true)
+    setApplyResult("")
+
+    const edits = controls.map((ctrl) => {
+      const src = ctrl.source!
+      return { file: src.file, anchor: src.anchor, prop: src.prop, value: src.format(css[ctrl.key]!) }
+    })
+
+    try {
+      const resp = await fetch("/__playground/apply-css", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ edits }),
+      })
+      const data = await resp.json()
+      const ok = data.results?.filter((r: any) => r.ok).length ?? 0
+      const fail = data.results?.filter((r: any) => !r.ok) ?? []
+      const lines = [`Applied ${ok}/${edits.length} edits`]
+      for (const f of fail) {
+        lines.push(`  FAIL ${f.file} ${f.prop}: ${f.error}`)
+      }
+      setApplyResult(lines.join("\n"))
+
+      if (ok > 0) {
+        // Clear overrides — values are now in source CSS, Vite will HMR.
+        resetCss()
+        // Wait for Vite HMR then re-read computed defaults
+        setTimeout(readDefaults, 500)
+      }
+    } catch (err) {
+      setApplyResult(`Error: ${err}`)
+    } finally {
+      setApplying(false)
+    }
+  }
+
+  // ---- Panel collapse state ----
+  const [panels, setPanels] = createStore({
+    generators: true,
+    css: true,
+    export: false,
+  })
+
+  // ---- Group collapse state for CSS ----
+  const [collapsed, setCollapsed] = createStore<Record<string, boolean>>({})
+  const groups = createMemo(() => {
+    const result = new Map<string, CSSControl[]>()
+    for (const ctrl of CSS_CONTROLS) {
+      if (!result.has(ctrl.group)) result.set(ctrl.group, [])
+      result.get(ctrl.group)!.push(ctrl)
+    }
+    return result
+  })
+
+  // ---- Shared button styles ----
+  const sectionLabel = {
+    "font-size": "11px",
+    color: "var(--text-weak)",
+    "margin-bottom": "4px",
+    "text-transform": "uppercase",
+    "letter-spacing": "0.5px",
+  } as const
+  const btnStyle = {
+    padding: "4px 8px",
+    "border-radius": "4px",
+    border: "1px solid var(--border-weak-base)",
+    background: "var(--surface-base)",
+    cursor: "pointer",
+    "font-size": "12px",
+    color: "var(--text-base)",
+  } as const
+  const btnAccent = {
+    ...btnStyle,
+    border: "1px solid var(--border-interactive-base)",
+    background: "var(--surface-interactive-weak)",
+    "font-weight": "500",
+    color: "var(--text-interactive-base)",
+  } as const
+  const btnDanger = {
+    ...btnStyle,
+    border: "1px solid var(--border-critical-base)",
+    background: "transparent",
+    color: "var(--text-on-critical-base)",
+  } as const
+
+  return (
+    <div style={{ display: "flex", height: "calc(100vh - 48px)", gap: "0", overflow: "hidden", margin: "-24px" }}>
+      {/* Inject dynamic style element */}
+      <style ref={styleEl!} />
+
+      {/* Left sidebar: controls */}
+      <div
+        style={{
+          width: "320px",
+          "min-width": "320px",
+          "border-right": "1px solid var(--border-weak-base)",
+          overflow: "auto",
+          "background-color": "var(--background-stronger)",
+          "scrollbar-width": "none",
+        }}
+      >
+        {/* Generate section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("generators", (v) => !v)}
+          >
+            Generate Messages
+            <span>{panels.generators ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.generators}>
+            <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
+              {/* ---- User messages ---- */}
+              <div style={sectionLabel}>User messages</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Creates a new turn (user + empty assistant)
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(USER_VARIANTS) as (keyof typeof USER_VARIANTS)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addUser(key)}>
+                      {USER_VARIANTS[key].label}
+                    </button>
+                  )}
+                </For>
+              </div>
+
+              {/* ---- Text and reasoning blocks ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Appends to the last turn's assistant parts
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(MARKDOWN_SAMPLES) as (keyof typeof MARKDOWN_SAMPLES)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addText(key)}>
+                      {key}
+                    </button>
+                  )}
+                </For>
+                <button style={btnStyle} onClick={addReasoning}>
+                  reasoning
+                </button>
+              </div>
+
+              {/* ---- Tool calls ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Tool calls</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Appends to the last turn's assistant parts
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <For each={Object.keys(TOOL_SAMPLES) as (keyof typeof TOOL_SAMPLES)[]}>
+                  {(key) => (
+                    <button style={btnStyle} onClick={() => addTool(key)}>
+                      {key}
+                    </button>
+                  )}
+                </For>
+              </div>
+
+              {/* ---- Composite (full turns) ---- */}
+              <div style={{ ...sectionLabel, "margin-top": "8px" }}>Composite turns</div>
+              <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+                Creates complete user + assistant turns
+              </div>
+              <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+                <button style={btnStyle} onClick={addContextGroupTurn}>
+                  context group
+                </button>
+                <button style={btnStyle} onClick={addReasoningFullTurn}>
+                  full turn
+                </button>
+                <button style={btnAccent} onClick={addKitchenSink}>
+                  kitchen sink
+                </button>
+              </div>
+
+              <div style={{ "margin-top": "8px" }}>
+                <button style={btnDanger} onClick={clearAll}>
+                  Clear all
+                </button>
+              </div>
+            </div>
+          </Show>
+        </div>
+
+        {/* CSS Controls section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("css", (v) => !v)}
+          >
+            CSS Controls
+            <span>{panels.css ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.css}>
+            <div style={{ padding: "0 12px 12px" }}>
+              <button
+                style={{
+                  padding: "4px 8px",
+                  "border-radius": "4px",
+                  border: "1px solid var(--border-weak-base)",
+                  background: "var(--surface-base)",
+                  cursor: "pointer",
+                  "font-size": "11px",
+                  color: "var(--text-base)",
+                  "margin-bottom": "8px",
+                }}
+                onClick={resetCss}
+              >
+                Reset all
+              </button>
+
+              <For each={[...groups().entries()]}>
+                {([group, controls]) => (
+                  <div style={{ "margin-bottom": "4px" }}>
+                    <button
+                      style={{
+                        width: "100%",
+                        display: "flex",
+                        "align-items": "center",
+                        "justify-content": "space-between",
+                        padding: "6px 0",
+                        background: "none",
+                        border: "none",
+                        "border-bottom": "1px solid var(--border-weaker-base)",
+                        cursor: "pointer",
+                        "font-size": "11px",
+                        "font-weight": "500",
+                        color: "var(--text-base)",
+                        "text-transform": "uppercase",
+                        "letter-spacing": "0.5px",
+                      }}
+                      onClick={() => setCollapsed(group, (v) => !v)}
+                    >
+                      {group}
+                      <span style={{ "font-size": "10px" }}>{collapsed[group] ? "+" : "−"}</span>
+                    </button>
+                    <Show when={!collapsed[group]}>
+                      <div style={{ padding: "6px 0", display: "flex", "flex-direction": "column", gap: "8px" }}>
+                        <For each={controls}>
+                          {(ctrl) => (
+                            <div style={{ display: "flex", "flex-direction": "column", gap: "2px" }}>
+                              <div
+                                style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}
+                              >
+                                <label
+                                  style={{
+                                    "font-size": "11px",
+                                    color: "var(--text-base)",
+                                  }}
+                                >
+                                  {ctrl.label}
+                                </label>
+                                <span
+                                  style={{
+                                    "font-size": "11px",
+                                    color:
+                                      css[ctrl.key] !== undefined ? "var(--text-interactive-base)" : "var(--text-weak)",
+                                    "font-family": "var(--font-family-mono)",
+                                    "min-width": "40px",
+                                    "text-align": "right",
+                                  }}
+                                >
+                                  {css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial}
+                                  {ctrl.unit ?? ""}
+                                </span>
+                              </div>
+                              <input
+                                type="range"
+                                min={ctrl.min ?? "0"}
+                                max={ctrl.max ?? "100"}
+                                step={ctrl.step ?? "1"}
+                                value={css[ctrl.key] ?? defaults[ctrl.key] ?? ctrl.initial}
+                                onInput={(e) => setCssValue(ctrl.key, e.currentTarget.value)}
+                                style={{
+                                  width: "100%",
+                                  height: "4px",
+                                  "accent-color": "var(--text-interactive-base)",
+                                  cursor: "pointer",
+                                }}
+                              />
+                            </div>
+                          )}
+                        </For>
+                      </div>
+                    </Show>
+                  </div>
+                )}
+              </For>
+            </div>
+          </Show>
+        </div>
+
+        {/* Export section */}
+        <div style={{ "border-bottom": "1px solid var(--border-weak-base)" }}>
+          <button
+            style={{
+              width: "100%",
+              display: "flex",
+              "align-items": "center",
+              "justify-content": "space-between",
+              padding: "10px 12px",
+              background: "none",
+              border: "none",
+              cursor: "pointer",
+              "font-weight": "500",
+              "font-size": "13px",
+              color: "var(--text-strong)",
+            }}
+            onClick={() => setPanels("export", (v) => !v)}
+          >
+            Export CSS
+            <span>{panels.export ? "−" : "+"}</span>
+          </button>
+          <Show when={panels.export}>
+            <div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "8px" }}>
+              <button style={btnAccent} onClick={() => setExported(exportCss())}>
+                Copy CSS to clipboard
+              </button>
+              <button
+                style={{
+                  ...btnAccent,
+                  opacity: changedControls().length === 0 || applying() ? "0.5" : "1",
+                  cursor: changedControls().length === 0 || applying() ? "not-allowed" : "pointer",
+                }}
+                disabled={changedControls().length === 0 || applying()}
+                onClick={applyToSource}
+              >
+                {applying()
+                  ? "Applying..."
+                  : `Apply ${changedControls().length} edit${changedControls().length === 1 ? "" : "s"} to source`}
+              </button>
+              <Show when={changedControls().length > 0}>
+                <div
+                  style={{
+                    "font-size": "10px",
+                    color: "var(--text-weaker)",
+                    "line-height": "1.4",
+                  }}
+                >
+                  <For each={changedControls()}>
+                    {(ctrl) => (
+                      <div>
+                        {ctrl.source!.file}: {ctrl.property} = {css[ctrl.key]}
+                        {ctrl.unit}
+                      </div>
+                    )}
+                  </For>
+                </div>
+              </Show>
+              <Show when={applyResult()}>
+                <pre
+                  style={{
+                    padding: "8px",
+                    "border-radius": "4px",
+                    background: "var(--surface-inset-base)",
+                    border: "1px solid var(--border-weak-base)",
+                    "font-size": "11px",
+                    "font-family": "var(--font-family-mono)",
+                    "line-height": "1.5",
+                    "white-space": "pre-wrap",
+                    "word-break": "break-all",
+                    "max-height": "200px",
+                    "overflow-y": "auto",
+                    color: "var(--text-base)",
+                  }}
+                >
+                  {applyResult()}
+                </pre>
+              </Show>
+              <Show when={exported()}>
+                <pre
+                  style={{
+                    padding: "8px",
+                    "border-radius": "4px",
+                    background: "var(--surface-inset-base)",
+                    border: "1px solid var(--border-weak-base)",
+                    "font-size": "11px",
+                    "font-family": "var(--font-family-mono)",
+                    "line-height": "1.5",
+                    "white-space": "pre-wrap",
+                    "word-break": "break-all",
+                    "max-height": "300px",
+                    "overflow-y": "auto",
+                    color: "var(--text-base)",
+                  }}
+                >
+                  {exported()}
+                </pre>
+              </Show>
+            </div>
+          </Show>
+        </div>
+      </div>
+
+      {/* Main area: timeline preview */}
+      <div
+        ref={previewRef!}
+        style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}
+      >
+        <DataProvider data={data()} directory="/project">
+          <FileComponentProvider component={FileStub}>
+            <div
+              style={{
+                "max-width": "800px",
+                margin: "0 auto",
+                padding: "16px 0",
+              }}
+            >
+              <Show
+                when={userMessages().length > 0}
+                fallback={
+                  <div
+                    style={{
+                      display: "flex",
+                      "align-items": "center",
+                      "justify-content": "center",
+                      height: "400px",
+                      color: "var(--text-weak)",
+                      "font-size": "14px",
+                    }}
+                  >
+                    Click a generator button to add messages
+                  </div>
+                }
+              >
+                <div
+                  role="log"
+                  data-slot="session-turn-list"
+                  style={{ display: "flex", "flex-direction": "column", width: "100%", padding: "0 20px" }}
+                >
+                  <For each={userMessages()}>
+                    {(msg) => (
+                      <div style={{ width: "100%" }}>
+                        <SessionTurn
+                          sessionID={SESSION_ID}
+                          messageID={msg.id}
+                          messages={state.messages}
+                          active={false}
+                          showReasoningSummaries={true}
+                          shellToolDefaultOpen={true}
+                          editToolDefaultOpen={true}
+                          classes={{
+                            root: "min-w-0 w-full relative",
+                            content: "flex flex-col justify-between !overflow-visible",
+                            container: "w-full",
+                          }}
+                        />
+                      </div>
+                    )}
+                  </For>
+                </div>
+              </Show>
+            </div>
+          </FileComponentProvider>
+        </DataProvider>
+      </div>
+    </div>
+  )
+}
+
+// ---------------------------------------------------------------------------
+// Story export
+// ---------------------------------------------------------------------------
+export default {
+  title: "Playground/Timeline",
+  id: "playground-timeline",
+  parameters: {
+    layout: "fullscreen",
+  },
+}
+
+export const Basic = {
+  render: () => <Playground />,
+}

+ 134 - 7
script/beta.ts

@@ -1,6 +1,9 @@
 #!/usr/bin/env bun
 
 import { $ } from "bun"
+import fs from "fs/promises"
+
+const model = "opencode/gpt-5.3-codex"
 
 interface PR {
   number: number
@@ -50,17 +53,76 @@ async function cleanup() {
   } catch {}
 }
 
-async function fix(pr: PR, files: string[]) {
+function lines(prs: PR[]) {
+  return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
+}
+
+async function typecheck() {
+  console.log("  Running typecheck...")
+
+  try {
+    await $`bun typecheck`.cwd("packages/opencode")
+    return true
+  } catch (err) {
+    console.log(`Typecheck failed: ${err}`)
+    return false
+  }
+}
+
+async function build() {
+  console.log("  Running final build smoke check...")
+
+  try {
+    await $`./script/build.ts --single`.cwd("packages/opencode")
+    return true
+  } catch (err) {
+    console.log(`Build failed: ${err}`)
+    return false
+  }
+}
+
+async function install() {
+  console.log("  Regenerating bun.lock...")
+
+  try {
+    await fs.rm("bun.lock", { force: true })
+    await $`bun install`
+    await $`git add bun.lock`
+    return true
+  } catch (err) {
+    console.log(`Install failed: ${err}`)
+    return false
+  }
+}
+
+async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
   console.log(`  Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
+
+  const done = lines(prs.filter((x) => applied.includes(x.number)))
+  const next = lines(prs.slice(idx + 1))
+
   const prompt = [
     `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
-    `Only touch these files: ${files.join(", ")}.`,
+    `PR #${pr.number}: ${pr.title}`,
+    `Start with these conflicted files: ${files.join(", ")}.`,
+    `Merged PRs on HEAD:\n${done}`,
+    `Pending PRs after this one (context only):\n${next}`,
+    "IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
+    "Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
+    "Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
+    "If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
+    "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
+    "If a PR already changed an import, keep that change.",
+    "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.",
+    "If typecheck fails, you may also update any files reported by typecheck.",
+    "Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
+    "Fix any merge-caused typecheck errors before finishing.",
     "Keep the merge in progress, do not abort the merge, and do not create a commit.",
-    "When done, leave the working tree with no unmerged files.",
+    "When done, leave the working tree with no unmerged files and a passing typecheck.",
   ].join("\n")
 
   try {
-    await $`opencode run -m opencode/gpt-5.3-codex ${prompt}`
+    await $`opencode run -m ${model} ${prompt}`
   } catch (err) {
     console.log(`  opencode failed: ${err}`)
     return false
@@ -72,10 +134,68 @@ async function fix(pr: PR, files: string[]) {
     return false
   }
 
+  if (files.includes("bun.lock") && !(await install())) return false
+
+  if (!(await typecheck())) return false
+
   console.log("  Conflicts resolved with opencode")
   return true
 }
 
+async function smoke(prs: PR[], applied: number[]) {
+  console.log("\nRunning final smoke check with opencode...")
+
+  const done = lines(prs.filter((x) => applied.includes(x.number)))
+  const prompt = [
+    "The beta merge batch is complete.",
+    `Merged PRs on HEAD:\n${done}`,
+    "Run `bun typecheck` in `packages/opencode`.",
+    "Run `./script/build.ts --single` in `packages/opencode`.",
+    "Fix any merge-caused issues until both commands pass.",
+    "Do not create a commit.",
+  ].join("\n")
+
+  try {
+    await $`opencode run -m ${model} ${prompt}`
+  } catch (err) {
+    console.log(`Smoke fix failed: ${err}`)
+    return false
+  }
+
+  if (!(await typecheck())) {
+    return false
+  }
+
+  if (!(await build())) {
+    return false
+  }
+
+  const out = await $`git status --porcelain`.text()
+  if (!out.trim()) {
+    console.log("Smoke check passed")
+    return true
+  }
+
+  try {
+    await $`git add -A`
+    await $`git commit -m "Fix beta integration"`
+  } catch (err) {
+    console.log(`Failed to commit smoke fixes: ${err}`)
+    return false
+  }
+
+  if (!(await typecheck())) {
+    return false
+  }
+
+  if (!(await build())) {
+    return false
+  }
+
+  console.log("Smoke check passed")
+  return true
+}
+
 async function main() {
   console.log("Fetching open PRs with beta label...")
 
@@ -99,8 +219,8 @@ async function main() {
   const applied: number[] = []
   const failed: FailedPR[] = []
 
-  for (const pr of prs) {
-    console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
+  for (const [idx, pr] of prs.entries()) {
+    console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
 
     console.log("  Fetching PR head...")
     try {
@@ -119,7 +239,7 @@ async function main() {
       const files = await conflicts()
       if (files.length > 0) {
         console.log("  Failed to merge (conflicts)")
-        if (!(await fix(pr, files))) {
+        if (!(await fix(pr, files, prs, applied, idx))) {
           await cleanup()
           failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
           await commentOnPR(pr.number, "Merge conflicts with dev branch")
@@ -174,6 +294,13 @@ async function main() {
     throw new Error(`${failed.length} PR(s) failed to merge`)
   }
 
+  if (applied.length > 0) {
+    const ok = await smoke(prs, applied)
+    if (!ok) {
+      throw new Error("Final smoke check failed")
+    }
+  }
+
   console.log("\nChecking if beta branch has changes...")
   await $`git fetch origin beta`
 

+ 97 - 0
script/github/close-issues.ts

@@ -0,0 +1,97 @@
+#!/usr/bin/env bun
+
+const repo = "anomalyco/opencode"
+const days = 60
+const msg =
+  "To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one."
+
+const token = process.env.GITHUB_TOKEN
+if (!token) {
+  console.error("GITHUB_TOKEN environment variable is required")
+  process.exit(1)
+}
+
+const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
+
+type Issue = {
+  number: number
+  updated_at: string
+}
+
+const headers = {
+  Authorization: `Bearer ${token}`,
+  "Content-Type": "application/json",
+  Accept: "application/vnd.github+json",
+  "X-GitHub-Api-Version": "2022-11-28",
+}
+
+async function close(num: number) {
+  const base = `https://api.github.com/repos/${repo}/issues/${num}`
+
+  const comment = await fetch(`${base}/comments`, {
+    method: "POST",
+    headers,
+    body: JSON.stringify({ body: msg }),
+  })
+  if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`)
+
+  const patch = await fetch(base, {
+    method: "PATCH",
+    headers,
+    body: JSON.stringify({ state: "closed", state_reason: "completed" }),
+  })
+  if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`)
+
+  console.log(`Closed https://github.com/${repo}/issues/${num}`)
+}
+
+async function main() {
+  let page = 1
+  let closed = 0
+
+  while (true) {
+    const res = await fetch(
+      `https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`,
+      { headers },
+    )
+    if (!res.ok) throw new Error(res.statusText)
+
+    const all = (await res.json()) as Issue[]
+    if (all.length === 0) break
+    console.log(`Fetched page ${page} ${all.length} issues`)
+
+    const stale: number[] = []
+    for (const i of all) {
+      const updated = new Date(i.updated_at)
+      if (updated < cutoff) {
+        stale.push(i.number)
+      } else {
+        console.log(`\nFound fresh issue #${i.number}, stopping`)
+        if (stale.length > 0) {
+          for (const num of stale) {
+            await close(num)
+            closed++
+          }
+        }
+        console.log(`Closed ${closed} issues total`)
+        return
+      }
+    }
+
+    if (stale.length > 0) {
+      for (const num of stale) {
+        await close(num)
+        closed++
+      }
+    }
+
+    page++
+  }
+
+  console.log(`Closed ${closed} issues total`)
+}
+
+main().catch((err) => {
+  console.error("Error:", err)
+  process.exit(1)
+})

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.