Преглед изворни кода

Merge branch 'dev' into kit/effectify-pty

Kit Langton пре 4 недеља
родитељ
комит
771f1918b5
38 измењених фајлова са 669 додато и 242 уклоњено
  1. 21 5
      .github/workflows/test.yml
  2. 15 7
      bun.lock
  3. 4 4
      nix/hashes.json
  4. 5 3
      package.json
  5. 145 7
      packages/app/e2e/actions.ts
  6. 39 5
      packages/app/e2e/fixtures.ts
  7. 10 43
      packages/app/e2e/projects/projects-switch.spec.ts
  8. 24 47
      packages/app/e2e/projects/workspace-new-session.spec.ts
  9. 13 25
      packages/app/e2e/session/session-model-persistence.spec.ts
  10. 1 0
      packages/app/src/context/global-sync.tsx
  11. 10 0
      packages/app/src/context/global-sync/child-store.ts
  12. 9 1
      packages/app/src/pages/error.tsx
  13. 27 20
      packages/app/src/pages/layout.tsx
  14. 2 2
      packages/app/src/pages/layout/helpers.ts
  15. 2 5
      packages/app/src/pages/layout/sidebar-project.tsx
  16. 2 2
      packages/app/src/pages/layout/sidebar-workspace.tsx
  17. 12 5
      packages/opencode/package.json
  18. 54 0
      packages/opencode/script/build-node.ts
  19. 1 1
      packages/opencode/src/global/index.ts
  20. 4 7
      packages/opencode/src/mcp/index.ts
  21. 1 0
      packages/opencode/src/node.ts
  22. 15 0
      packages/opencode/src/permission/evaluate.ts
  23. 3 6
      packages/opencode/src/permission/index.ts
  24. 9 0
      packages/opencode/src/provider/provider.ts
  25. 1 1
      packages/opencode/src/server/routes/project.ts
  26. 1 1
      packages/opencode/src/session/message-v2.ts
  27. 12 10
      packages/opencode/src/session/prompt.ts
  28. 14 6
      packages/opencode/src/session/summary.ts
  29. 8 0
      packages/opencode/src/storage/db.bun.ts
  30. 8 0
      packages/opencode/src/storage/db.node.ts
  31. 12 24
      packages/opencode/src/storage/db.ts
  32. 1 1
      packages/opencode/src/tool/registry.ts
  33. 2 2
      packages/opencode/src/tool/truncate-effect.ts
  34. 1 1
      packages/opencode/src/util/process.ts
  35. 5 1
      packages/opencode/src/util/which.ts
  36. 10 0
      packages/opencode/test/tool/truncation.test.ts
  37. 108 0
      patches/@ai-sdk%[email protected]
  38. 58 0
      patches/[email protected]

+ 21 - 5
.github/workflows/test.yml

@@ -50,20 +50,17 @@ jobs:
 
   e2e:
     name: e2e (${{ matrix.settings.name }})
-    needs: unit
     strategy:
       fail-fast: false
       matrix:
         settings:
           - name: linux
             host: blacksmith-4vcpu-ubuntu-2404
-            playwright: bunx playwright install --with-deps
           - name: windows
             host: blacksmith-4vcpu-windows-2025
-            playwright: bunx playwright install
     runs-on: ${{ matrix.settings.host }}
     env:
-      PLAYWRIGHT_BROWSERS_PATH: 0
+      PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
     defaults:
       run:
         shell: bash
@@ -76,9 +73,28 @@ jobs:
       - name: Setup Bun
         uses: ./.github/actions/setup-bun
 
+      - name: Read Playwright version
+        id: playwright-version
+        run: |
+          version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
+          echo "version=$version" >> "$GITHUB_OUTPUT"
+
+      - name: Cache Playwright browsers
+        id: playwright-cache
+        uses: actions/cache@v4
+        with:
+          path: ${{ github.workspace }}/.playwright-browsers
+          key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
+
+      - name: Install Playwright system dependencies
+        if: runner.os == 'Linux'
+        working-directory: packages/app
+        run: bunx playwright install-deps chromium
+
       - name: Install Playwright browsers
+        if: steps.playwright-cache.outputs.cache-hit != 'true'
         working-directory: packages/app
-        run: ${{ matrix.settings.playwright }}
+        run: bunx playwright install chromium
 
       - name: Run app e2e tests
         run: bun --cwd packages/app test:e2e:local

+ 15 - 7
bun.lock

@@ -355,7 +355,7 @@
         "cross-spawn": "^7.0.6",
         "decimal.js": "10.5.0",
         "diff": "catalog:",
-        "drizzle-orm": "1.0.0-beta.16-ea816b6",
+        "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
         "glob": "13.0.5",
@@ -409,8 +409,8 @@
         "@types/which": "3.0.4",
         "@types/yargs": "17.0.33",
         "@typescript/native-preview": "catalog:",
-        "drizzle-kit": "1.0.0-beta.16-ea816b6",
-        "drizzle-orm": "1.0.0-beta.16-ea816b6",
+        "drizzle-kit": "catalog:",
+        "drizzle-orm": "catalog:",
         "typescript": "catalog:",
         "vscode-languageserver-types": "3.17.5",
         "why-is-node-running": "3.2.2",
@@ -586,6 +586,8 @@
   ],
   "patchedDependencies": {
     "@openrouter/[email protected]": "patches/@openrouter%[email protected]",
+    "[email protected]": "patches/[email protected]",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
   },
   "overrides": {
@@ -616,8 +618,8 @@
     "ai": "5.0.124",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
-    "drizzle-kit": "1.0.0-beta.16-ea816b6",
-    "drizzle-orm": "1.0.0-beta.16-ea816b6",
+    "drizzle-kit": "1.0.0-beta.19-d95b7a4",
+    "drizzle-orm": "1.0.0-beta.19-d95b7a4",
     "effect": "4.0.0-beta.35",
     "fuzzysort": "3.1.0",
     "hono": "4.10.7",
@@ -2736,9 +2738,9 @@
 
     "dotenv-expand": ["[email protected]", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
 
-    "drizzle-kit": ["[email protected]6-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
+    "drizzle-kit": ["[email protected]9-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
 
-    "drizzle-orm": ["[email protected]6-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
+    "drizzle-orm": ["[email protected]9-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
 
     "dset": ["[email protected]", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
 
@@ -3020,6 +3022,8 @@
 
     "get-symbol-description": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
 
+    "get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
+
     "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
 
     "gifwrap": ["[email protected]", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -4108,6 +4112,8 @@
 
     "resolve-from": ["[email protected]", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
 
+    "resolve-pkg-maps": ["[email protected]", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
     "responselike": ["[email protected]", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
 
     "restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -5386,6 +5392,8 @@
 
     "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
+    "db0/drizzle-orm": ["[email protected]", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
+
     "defaults/clone": ["[email protected]", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
 
     "dir-compare/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
-    "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
-    "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
-    "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
+    "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
+    "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
+    "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
+    "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
   }
 }

+ 5 - 3
package.json

@@ -43,8 +43,8 @@
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "dompurify": "3.3.1",
-      "drizzle-kit": "1.0.0-beta.16-ea816b6",
-      "drizzle-orm": "1.0.0-beta.16-ea816b6",
+      "drizzle-kit": "1.0.0-beta.19-d95b7a4",
+      "drizzle-orm": "1.0.0-beta.19-d95b7a4",
       "effect": "4.0.0-beta.35",
       "ai": "5.0.124",
       "hono": "4.10.7",
@@ -112,6 +112,8 @@
   },
   "patchedDependencies": {
     "@standard-community/[email protected]": "patches/@standard-community%[email protected]",
-    "@openrouter/[email protected]": "patches/@openrouter%[email protected]"
+    "@openrouter/[email protected]": "patches/@openrouter%[email protected]",
+    "@ai-sdk/[email protected]": "patches/@ai-sdk%[email protected]",
+    "[email protected]": "patches/[email protected]"
   }
 }

+ 145 - 7
packages/app/e2e/actions.ts

@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
+  projectSwitchSelector,
   projectMenuTriggerSelector,
   projectCloseMenuSelector,
   projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
   workspaceMenuTriggerSelector,
 } from "./selectors"
 
+const phase = new WeakMap<Page, "test" | "cleanup">()
+
+export function setHealthPhase(page: Page, value: "test" | "cleanup") {
+  phase.set(page, value)
+}
+
+export function healthPhase(page: Page) {
+  return phase.get(page) ?? "test"
+}
+
 export async function defocus(page: Page) {
   await page
     .evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
 }
 
 export async function isSidebarClosed(page: Page) {
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
-  await expect(button).toBeVisible()
+  const button = await waitSidebarButton(page, "isSidebarClosed")
   return (await button.getAttribute("aria-expanded")) !== "true"
 }
 
+async function errorBoundaryText(page: Page) {
+  const title = page.getByRole("heading", { name: /something went wrong/i }).first()
+  if (!(await title.isVisible().catch(() => false))) return
+
+  const description = await page
+    .getByText(/an error occurred while loading the application\./i)
+    .first()
+    .textContent()
+    .catch(() => "")
+  const detail = await page
+    .getByRole("textbox", { name: /error details/i })
+    .first()
+    .inputValue()
+    .catch(async () =>
+      (
+        (await page
+          .getByRole("textbox", { name: /error details/i })
+          .first()
+          .textContent()
+          .catch(() => "")) ?? ""
+      ).trim(),
+    )
+
+  return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
+}
+
+export async function assertHealthy(page: Page, context: string) {
+  const text = await errorBoundaryText(page)
+  if (!text) return
+  console.log(`[e2e:error-boundary][${context}]\n${text}`)
+  throw new Error(`Error boundary during ${context}\n${text}`)
+}
+
+async function waitSidebarButton(page: Page, context: string) {
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
+  await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
+  await assertHealthy(page, context)
+  return button
+}
+
 export async function toggleSidebar(page: Page) {
   await defocus(page)
   await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
 export async function openSidebar(page: Page) {
   if (!(await isSidebarClosed(page))) return
 
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const button = await waitSidebarButton(page, "openSidebar")
   await button.click()
 
   const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
 export async function closeSidebar(page: Page) {
   if (await isSidebarClosed(page)) return
 
-  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const button = await waitSidebarButton(page, "closeSidebar")
   await button.click()
 
   const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
 }
 
 export async function openSettings(page: Page) {
+  await assertHealthy(page, "openSettings")
   await defocus(page)
 
   const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
 
   if (opened) return dialog
 
+  await assertHealthy(page, "openSettings")
+
   await page.getByRole("button", { name: "Settings" }).first().click()
   await expect(dialog).toBeVisible()
   return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
 
 export async function createTestProject() {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
+  const id = `e2e-${path.basename(root)}`
 
-  await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
+  await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
 
   execSync("git init", { cwd: root, stdio: "ignore" })
+  await fs.writeFile(path.join(root, ".git", "opencode"), id)
   execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
   execSync("git add -A", { cwd: root, stdio: "ignore" })
   execSync('git -c user.name="e2e" -c user.email="[email protected]" commit -m "init" --allow-empty', {
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
   return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
 }
 
+async function probeSession(page: Page) {
+  return page
+    .evaluate(() => {
+      const win = window as E2EWindow
+      const current = win.__opencode_e2e?.model?.current
+      if (!current) return null
+      return { dir: current.dir, sessionID: current.sessionID }
+    })
+    .catch(() => null as { dir?: string; sessionID?: string } | null)
+}
+
 export async function waitSlug(page: Page, skip: string[] = []) {
   let prev = ""
   let next = ""
   await expect
     .poll(
-      () => {
+      async () => {
+        await assertHealthy(page, "waitSlug")
         const slug = slugFromUrl(page.url())
         if (!slug) return ""
         if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
   await expect
     .poll(
       async () => {
+        await assertHealthy(page, "waitDir")
         const slug = slugFromUrl(page.url())
         if (!slug) return ""
         return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
   return { directory: target, slug: base64Encode(target) }
 }
 
+export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
+  const target = await resolveDirectory(input.directory)
+  await expect
+    .poll(
+      async () => {
+        await assertHealthy(page, "waitSession")
+        const slug = slugFromUrl(page.url())
+        if (!slug) return false
+        const resolved = await resolveSlug(slug).catch(() => undefined)
+        if (!resolved || resolved.directory !== target) return false
+        if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
+
+        const state = await probeSession(page)
+        if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
+        if (state?.dir) {
+          const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
+          if (dir !== target) return false
+        }
+
+        return page
+          .locator(promptSelector)
+          .first()
+          .isVisible()
+          .catch(() => false)
+      },
+      { timeout: 45_000 },
+    )
+    .toBe(true)
+  return { directory: target, slug: base64Encode(target) }
+}
+
+export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
+  const sdk = createSdk(directory)
+  const target = await resolveDirectory(directory)
+
+  await expect
+    .poll(
+      async () => {
+        const data = await sdk.session
+          .get({ sessionID })
+          .then((x) => x.data)
+          .catch(() => undefined)
+        if (!data?.directory) return ""
+        return resolveDirectory(data.directory).catch(() => data.directory)
+      },
+      { timeout },
+    )
+    .toBe(target)
+
+  await expect
+    .poll(
+      async () => {
+        const items = await sdk.session
+          .messages({ sessionID, limit: 20 })
+          .then((x) => x.data ?? [])
+          .catch(() => [])
+        return items.some((item) => item.info.role === "user")
+      },
+      { timeout },
+    )
+    .toBe(true)
+}
+
 export function sessionIDFromUrl(url: string) {
   const match = /\/session\/([^/?#]+)/.exec(url)
   return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
 }
 
 export async function openProjectMenu(page: Page, projectSlug: string) {
+  await openSidebar(page)
+  const item = page.locator(projectSwitchSelector(projectSlug)).first()
+  await expect(item).toBeVisible()
+  await item.hover()
+
   const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
   await expect(trigger).toHaveCount(1)
+  await expect(trigger).toBeVisible()
 
   const menu = page
     .locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
   const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
 
   const clicked = await trigger
-    .click({ timeout: 1500 })
+    .click({ force: true, timeout: 1500 })
     .then(() => true)
     .catch(() => false)
 

+ 39 - 5
packages/app/e2e/fixtures.ts

@@ -1,7 +1,16 @@
 import { test as base, expect, type Page } from "@playwright/test"
 import type { E2EWindow } from "../src/testing/terminal"
-import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
-import { promptSelector } from "./selectors"
+import {
+  healthPhase,
+  cleanupSession,
+  cleanupTestProject,
+  createTestProject,
+  setHealthPhase,
+  seedProjects,
+  sessionIDFromUrl,
+  waitSlug,
+  waitSession,
+} from "./actions"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
 export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
 }
 
 export const test = base.extend<TestFixtures, WorkerFixtures>({
+  page: async ({ page }, use) => {
+    let boundary: string | undefined
+    setHealthPhase(page, "test")
+    const consoleHandler = (msg: { text(): string }) => {
+      const text = msg.text()
+      if (!text.includes("[e2e:error-boundary]")) return
+      if (healthPhase(page) === "cleanup") {
+        console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
+        return
+      }
+      boundary ||= text
+      console.log(text)
+    }
+    const pageErrorHandler = (err: Error) => {
+      console.log(`[e2e:pageerror] ${err.stack || err.message}`)
+    }
+    page.on("console", consoleHandler)
+    page.on("pageerror", pageErrorHandler)
+    await use(page)
+    page.off("console", consoleHandler)
+    page.off("pageerror", pageErrorHandler)
+    if (boundary) throw new Error(boundary)
+  },
   directory: [
     async ({}, use) => {
       const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 
     const gotoSession = async (sessionID?: string) => {
       await page.goto(sessionPath(directory, sessionID))
-      await expect(page.locator(promptSelector)).toBeVisible()
+      await waitSession(page, { directory, sessionID })
     }
     await use(gotoSession)
   },
   withProject: async ({ page }, use) => {
     await use(async (callback, options) => {
       const root = await createTestProject()
-      const slug = dirSlug(root)
       const sessions = new Map<string, string>()
       const dirs = new Set<string>()
       await seedStorage(page, { directory: root, extra: options?.extra })
 
       const gotoSession = async (sessionID?: string) => {
         await page.goto(sessionPath(root, sessionID))
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await waitSession(page, { directory: root, sessionID })
         const current = sessionIDFromUrl(page.url())
         if (current) trackSession(current)
       }
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 
       try {
         await gotoSession()
+        const slug = await waitSlug(page)
         return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
       } finally {
+        setHealthPhase(page, "cleanup")
         await Promise.allSettled(
           Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
         )
         await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
         await cleanupTestProject(root)
+        setHealthPhase(page, "test")
       }
     })
   },

+ 10 - 43
packages/app/e2e/projects/projects-switch.spec.ts

@@ -1,5 +1,4 @@
 import { base64Decode } from "@opencode-ai/util/encode"
-import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
 import {
   defocus,
@@ -7,43 +6,14 @@ import {
   cleanupTestProject,
   openSidebar,
   sessionIDFromUrl,
-  waitDir,
+  setWorkspacesEnabled,
+  waitSession,
+  waitSessionSaved,
   waitSlug,
 } from "../actions"
 import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { dirSlug, resolveDirectory } from "../utils"
 
-async function workspaces(page: Page, directory: string, enabled: boolean) {
-  await page.evaluate(
-    ({ directory, enabled }: { directory: string; enabled: boolean }) => {
-      const key = "opencode.global.dat:layout"
-      const raw = localStorage.getItem(key)
-      const data = raw ? JSON.parse(raw) : {}
-      const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
-      const current =
-        sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
-          ? sidebar.workspaces
-          : {}
-      const next = { ...current }
-
-      if (enabled) next[directory] = true
-      if (!enabled) delete next[directory]
-
-      localStorage.setItem(
-        key,
-        JSON.stringify({
-          ...data,
-          sidebar: {
-            ...sidebar,
-            workspaces: next,
-          },
-        }),
-      )
-    },
-    { directory, enabled },
-  )
-}
-
 test("can switch between projects from sidebar", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
     await withProject(
       async ({ directory, slug, trackSession, trackDirectory }) => {
         await defocus(page)
-        await workspaces(page, directory, true)
-        await page.reload()
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await setWorkspacesEnabled(page, slug, true)
         await openSidebar(page)
         await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
         await expect(btn).toBeVisible()
         await btn.click({ force: true })
 
-        await waitSlug(page)
-        await waitDir(page, space)
+        await waitSession(page, { directory: space })
 
         // Create a session by sending a prompt
         const prompt = page.locator(promptSelector)
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
         const created = sessionIDFromUrl(page.url())
         if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
         trackSession(created, space)
+        await waitSessionSaved(space, created)
 
         await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
 
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
 
         const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
         await expect(otherButton).toBeVisible()
-        await otherButton.click()
-        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+        await otherButton.click({ force: true })
+        await waitSession(page, { directory: other })
 
         const rootButton = page.locator(projectSwitchSelector(slug)).first()
         await expect(rootButton).toBeVisible()
-        await rootButton.click()
+        await rootButton.click({ force: true })
 
-        await waitDir(page, space)
-        await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
+        await waitSession(page, { directory: space, sessionID: created })
         await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
       },
       { extra: [other] },

+ 24 - 47
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -1,6 +1,15 @@
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
+import {
+  openSidebar,
+  resolveSlug,
+  sessionIDFromUrl,
+  setWorkspacesEnabled,
+  waitDir,
+  waitSession,
+  waitSessionSaved,
+  waitSlug,
+} from "../actions"
 import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { createSdk } from "../utils"
 
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
 
 async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
   await openSidebar(page)
-  await expect
-    .poll(
-      async () => {
-        const row = page.locator(item(space)).first()
-        try {
-          await row.hover({ timeout: 500 })
-          return true
-        } catch {
-          return false
-        }
-      },
-      { timeout: 60_000 },
-    )
-    .toBe(true)
+  await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
 }
 
 async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
   await expect(next).toBeVisible()
   await next.click({ force: true })
 
-  return waitDir(page, space.directory)
+  await waitSession(page, { directory: space.directory })
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
 }
 
 async function createSessionFromWorkspace(
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
   space: { slug: string; raw: string; directory: string },
   text: string,
 ) {
-  const next = await openWorkspaceNewSession(page, space)
+  await openWorkspaceNewSession(page, space)
 
   const prompt = page.locator(promptSelector)
   await expect(prompt).toBeVisible()
-  await expect(prompt).toBeEditable()
-  await prompt.click()
-  await expect(prompt).toBeFocused()
   await prompt.fill(text)
-  await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
-  await prompt.press("Enter")
-
-  await waitDir(page, next.directory)
-  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
+  await page.keyboard.press("Enter")
 
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
   const sessionID = sessionIDFromUrl(page.url())
   if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-  await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
-  return { sessionID, slug: next.slug }
-}
 
-async function sessionDirectory(directory: string, sessionID: string) {
-  const info = await createSdk(directory)
-    .session.get({ sessionID })
-    .then((x) => x.data)
+  await waitSessionSaved(space.directory, sessionID)
+  await createSdk(space.directory)
+    .session.abort({ sessionID })
     .catch(() => undefined)
-  if (!info) return ""
-  return info.directory
+  return sessionID
 }
 
 test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
-  await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
+  await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
     await openSidebar(page)
     await setWorkspacesEnabled(page, root, true)
 
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
     trackDirectory(second.directory)
     await waitWorkspaceReady(page, second)
 
-    const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
-    trackSession(firstSession.sessionID, first.directory)
-
-    const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
-    trackSession(secondSession.sessionID, second.directory)
-
-    const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
-    trackSession(thirdSession.sessionID, first.directory)
-
-    await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
-    await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
-    await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
+    trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
+    trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
+    trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
   })
 })

+ 13 - 25
packages/app/e2e/session/session-model-persistence.spec.ts

@@ -1,6 +1,14 @@
 import type { Locator, Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
+import {
+  openSidebar,
+  resolveSlug,
+  sessionIDFromUrl,
+  setWorkspacesEnabled,
+  waitSession,
+  waitSessionIdle,
+  waitSlug,
+} from "../actions"
 import {
   promptAgentSelector,
   promptModelSelector,
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
 
 const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
 
-const dirKey = (state: Probe | null) => state?.dir ?? ""
-
 async function probe(page: Page): Promise<Probe | null> {
   return page.evaluate(() => {
     const win = window as Window & {
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
   })
 }
 
-async function currentDir(page: Page) {
-  let hit = ""
-  await expect
-    .poll(
-      async () => {
-        const next = dirKey(await probe(page))
-        if (next) hit = next
-        return next
-      },
-      { timeout: 30_000 },
-    )
-    .not.toBe("")
-  return hit
-}
-
 async function read(page: Page): Promise<Footer> {
   return {
     agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
 
 async function goto(page: Page, directory: string, sessionID?: string) {
   await page.goto(sessionPath(directory, sessionID))
-  await expect(page.locator(promptSelector)).toBeVisible()
-  await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
+  await waitSession(page, { directory, sessionID })
 }
 
 async function submit(page: Page, value: string) {
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
   await page.getByRole("button", { name: "New workspace" }).first().click()
 
   const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
-  await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
+  await waitSession(page, { directory: next.directory })
   return next
 }
 
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
   await button.click({ force: true })
 
   const next = await resolveSlug(await waitSlug(page))
-  await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
-  await expect(page.locator(promptSelector)).toBeVisible()
-  return currentDir(page)
+  return waitSession(page, { directory: next.directory }).then((item) => item.directory)
 }
 
 test("session model and variant restore per session without leaking into new sessions", async ({
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
     await waitUser(directory, first)
 
     await page.reload()
-    await expect(page.locator(promptSelector)).toBeVisible()
+    await waitSession(page, { directory, sessionID: first })
     await waitFooter(page, firstState)
 
     await gotoSession()

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

@@ -378,6 +378,7 @@ function createGlobalSync() {
       return globalStore.error
     },
     child: children.child,
+    peek: children.peek,
     bootstrap,
     updateConfig,
     project: projectApi,

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

@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
     return childStore
   }
 
+  function peek(directory: string, options: ChildOptions = {}) {
+    const childStore = ensureChild(directory)
+    const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap && childStore[0].status === "loading") {
+      input.onBootstrap(directory)
+    }
+    return childStore
+  }
+
   function projectMeta(directory: string, patch: ProjectMeta) {
     const [store, setStore] = ensureChild(directory)
     const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
     children,
     ensureChild,
     child,
+    peek,
     projectMeta,
     projectIcon,
     mark,

+ 9 - 1
packages/app/src/pages/error.tsx

@@ -1,11 +1,12 @@
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Logo } from "@opencode-ai/ui/logo"
 import { Button } from "@opencode-ai/ui/button"
-import { Component, Show } from "solid-js"
+import { Component, Show, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
 import { Icon } from "@opencode-ai/ui/icon"
+import type { E2EWindow } from "@/testing/terminal"
 
 export type InitError = {
   name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
     actionError: undefined as string | undefined,
   })
 
+  onMount(() => {
+    const win = window as E2EWindow
+    if (!win.__opencode_e2e) return
+    const detail = formatError(props.error, language.t)
+    console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
+  })
+
   async function checkForUpdates() {
     if (!platform.checkUpdate) return
     setStore("checking", true)

+ 27 - 20
packages/app/src/pages/layout.tsx

@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
   const theme = useTheme()
   const language = useLanguage()
   const initialDirectory = decode64(params.dir)
+  const route = createMemo(() => {
+    const slug = params.dir
+    if (!slug) return { slug, dir: "" }
+    const dir = decode64(slug)
+    if (!dir) return { slug, dir: "" }
+    return {
+      slug,
+      dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
+    }
+  })
   const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
   const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
     dark: "theme.scheme.dark",
   }
   const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
-  const currentDir = createMemo(() => decode64(params.dir) ?? "")
+  const currentDir = createMemo(() => route().dir)
 
   const [state, setState] = createStore({
     autoselect: !initialDirectory,
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
         }
 
         const currentSession = params.id
-        if (directory === currentDir() && props.sessionID === currentSession) return
-        if (directory === currentDir() && session?.parentID === currentSession) return
+        if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
+        if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
 
         dismissSessionAlert(sessionKey)
 
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
     const activeDir = currentDir()
     return workspaceIds(project).filter((directory) => {
       const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
-      const active = directory === activeDir
+      const active = workspaceKey(directory) === workspaceKey(activeDir)
       return expanded || active
     })
   })
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
       seen: lru,
       keep: sessionID,
       limit: PREFETCH_MAX_SESSIONS_PER_DIR,
-      preserve: directory === params.dir && params.id ? [params.id] : undefined,
+      preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
     })
   }
 
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
   })
 
   createEffect(() => {
-    params.dir
+    route()
     globalSDK.url
 
     prefetchToken.value += 1
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
   createEffect(
     on(
       () => {
-        const dir = params.dir
-        const directory = dir ? decode64(dir) : undefined
-        const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
-        return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
+        return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
       },
-      ([ready, dir, id, root, directory, resolved]) => {
-        if (!ready || !dir || !directory) {
+      ([ready, slug, id, root, dir]) => {
+        if (!ready || !slug || !dir) {
           activeRoute.session = ""
           activeRoute.sessionProject = ""
           activeRoute.directory = ""
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
           return
         }
 
-        const next = resolved || directory
-        const session = `${dir}/${id}`
+        const session = `${slug}/${id}`
 
         if (!root) {
           activeRoute.session = session
-          activeRoute.directory = next
+          activeRoute.directory = dir
           activeRoute.sessionProject = ""
           return
         }
 
         if (server.projects.last() !== root) server.projects.touch(root)
 
-        const changed = session !== activeRoute.session || next !== activeRoute.directory
+        const changed = session !== activeRoute.session || dir !== activeRoute.directory
         if (changed) {
           activeRoute.session = session
-          activeRoute.directory = next
-          activeRoute.sessionProject = syncSessionRoute(next, id, root)
+          activeRoute.directory = dir
+          activeRoute.sessionProject = syncSessionRoute(dir, id, root)
           return
         }
 
         if (root === activeRoute.sessionProject) return
-        activeRoute.directory = next
-        activeRoute.sessionProject = rememberSessionRoute(next, id, root)
+        activeRoute.directory = dir
+        activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
       },
     ),
   )
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
 
   const projectSidebarCtx: ProjectSidebarContext = {
     currentDir,
+    currentProject,
     sidebarOpened: () => layout.sidebar.opened(),
     sidebarHovering,
     hoverProject: () => state.hoverProject,

+ 2 - 2
packages/app/src/pages/layout/helpers.ts

@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
   stores.flatMap(roots).sort(sortSessions(now))[0]
 
 export function hasProjectPermissions<T>(
-  request: Record<string, T[] | undefined>,
+  request: Record<string, T[] | undefined> | undefined,
   include: (item: T) => boolean = () => true,
 ) {
-  return Object.values(request).some((list) => list?.some(include))
+  return Object.values(request ?? {}).some((list) => list?.some(include))
 }
 
 export const childMapByParent = (sessions: Session[] | undefined) => {

+ 2 - 5
packages/app/src/pages/layout/sidebar-project.tsx

@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
 
 export type ProjectSidebarContext = {
   currentDir: Accessor<string>
+  currentProject: Accessor<LocalProject | undefined>
   sidebarOpened: Accessor<boolean>
   sidebarHovering: Accessor<boolean>
   hoverProject: Accessor<string | undefined>
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
   const globalSync = useGlobalSync()
   const language = useLanguage()
   const sortable = createSortable(props.project.worktree)
-  const selected = createMemo(
-    () =>
-      props.project.worktree === props.ctx.currentDir() ||
-      props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
-  )
+  const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
   const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
   const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
   const dirs = createMemo(() => props.ctx.workspaceIds(props.project))

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

@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
-import { childMapByParent, sortedRootSessions } from "./helpers"
+import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
 
 type InlineEditorComponent = (props: {
   id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
   const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
   const children = createMemo(() => childMapByParent(workspaceStore.session))
   const local = createMemo(() => props.directory === props.project.worktree)
-  const active = createMemo(() => props.ctx.currentDir() === props.directory)
+  const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
   const workspaceValue = createMemo(() => {
     const branch = workspaceStore.vcs?.branch
     const name = branch ?? getFilename(props.directory)

+ 12 - 5
packages/opencode/package.json

@@ -26,6 +26,13 @@
   "exports": {
     "./*": "./src/*.ts"
   },
+  "imports": {
+    "#db": {
+      "bun": "./src/storage/db.bun.ts",
+      "node": "./src/storage/db.node.ts",
+      "default": "./src/storage/db.bun.ts"
+    }
+  },
   "devDependencies": {
     "@babel/core": "7.28.4",
     "@effect/language-service": "0.79.0",
@@ -50,8 +57,8 @@
     "@types/which": "3.0.4",
     "@types/yargs": "17.0.33",
     "@typescript/native-preview": "catalog:",
-    "drizzle-kit": "1.0.0-beta.16-ea816b6",
-    "drizzle-orm": "1.0.0-beta.16-ea816b6",
+    "drizzle-kit": "catalog:",
+    "drizzle-orm": "catalog:",
     "typescript": "catalog:",
     "vscode-languageserver-types": "3.17.5",
     "why-is-node-running": "3.2.2",
@@ -82,6 +89,7 @@
     "@ai-sdk/xai": "2.0.51",
     "@aws-sdk/credential-providers": "3.993.0",
     "@clack/prompts": "1.0.0-alpha.1",
+    "@effect/platform-node": "catalog:",
     "@gitlab/gitlab-ai-provider": "3.6.0",
     "@gitlab/opencode-gitlab-auth": "1.3.3",
     "@hono/standard-validator": "0.1.5",
@@ -97,7 +105,6 @@
     "@openrouter/ai-sdk-provider": "1.5.4",
     "@opentui/core": "0.1.87",
     "@opentui/solid": "0.1.87",
-    "@effect/platform-node": "catalog:",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",
@@ -113,7 +120,7 @@
     "cross-spawn": "^7.0.6",
     "decimal.js": "10.5.0",
     "diff": "catalog:",
-    "drizzle-orm": "1.0.0-beta.16-ea816b6",
+    "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
     "glob": "13.0.5",
@@ -144,6 +151,6 @@
     "zod-to-json-schema": "3.24.5"
   },
   "overrides": {
-    "drizzle-orm": "1.0.0-beta.16-ea816b6"
+    "drizzle-orm": "catalog:"
   }
 }

+ 54 - 0
packages/opencode/script/build-node.ts

@@ -0,0 +1,54 @@
+#!/usr/bin/env bun
+
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const dir = path.resolve(__dirname, "..")
+
+process.chdir(dir)
+
+// Load migrations from migration directories
+const migrationDirs = (
+  await fs.promises.readdir(path.join(dir, "migration"), {
+    withFileTypes: true,
+  })
+)
+  .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
+  .map((entry) => entry.name)
+  .sort()
+
+const migrations = await Promise.all(
+  migrationDirs.map(async (name) => {
+    const file = path.join(dir, "migration", name, "migration.sql")
+    const sql = await Bun.file(file).text()
+    const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
+    const timestamp = match
+      ? Date.UTC(
+          Number(match[1]),
+          Number(match[2]) - 1,
+          Number(match[3]),
+          Number(match[4]),
+          Number(match[5]),
+          Number(match[6]),
+        )
+      : 0
+    return { sql, timestamp, name }
+  }),
+)
+console.log(`Loaded ${migrations.length} migrations`)
+
+await Bun.build({
+  target: "node",
+  entrypoints: ["./src/node.ts"],
+  outdir: "./dist",
+  format: "esm",
+  external: ["jsonc-parser"],
+  define: {
+    OPENCODE_MIGRATIONS: JSON.stringify(migrations),
+  },
+})
+
+console.log("Build complete")

+ 1 - 1
packages/opencode/src/global/index.ts

@@ -18,7 +18,7 @@ export namespace Global {
       return process.env.OPENCODE_TEST_HOME || os.homedir()
     },
     data,
-    bin: path.join(data, "bin"),
+    bin: path.join(cache, "bin"),
     log: path.join(data, "log"),
     cache,
     config,

+ 4 - 7
packages/opencode/src/mcp/index.ts

@@ -11,6 +11,7 @@ import {
 } from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
+import { Process } from "../util/process"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
     const queue = [pid]
     while (queue.length > 0) {
       const current = queue.shift()!
-      const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
-      const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
-        () => [-1, ""] as const,
-      )
-      if (code !== 0) continue
-      for (const tok of out.trim().split(/\s+/)) {
+      const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
+      for (const tok of lines) {
         const cpid = parseInt(tok, 10)
-        if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
+        if (!isNaN(cpid) && !pids.includes(cpid)) {
           pids.push(cpid)
           queue.push(cpid)
         }

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

@@ -0,0 +1 @@
+export { Server } from "./server/server"

+ 15 - 0
packages/opencode/src/permission/evaluate.ts

@@ -0,0 +1,15 @@
+import { Wildcard } from "@/util/wildcard"
+
+type Rule = {
+  permission: string
+  pattern: string
+  action: "allow" | "deny" | "ask"
+}
+
+export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
+  const rules = rulesets.flat()
+  const match = rules.findLast(
+    (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+  )
+  return match ?? { action: "ask", permission, pattern: "*" }
+}

+ 3 - 6
packages/opencode/src/permission/index.ts

@@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard"
 import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import os from "os"
 import z from "zod"
+import { evaluate as evalRule } from "./evaluate"
 import { PermissionID } from "./schema"
 
 export namespace PermissionNext {
@@ -125,12 +126,8 @@ export namespace PermissionNext {
   }
 
   export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-    const rules = rulesets.flat()
-    log.info("evaluate", { permission, pattern, ruleset: rules })
-    const match = rules.findLast(
-      (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
-    )
-    return match ?? { action: "ask", permission, pattern: "*" }
+    log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
+    return evalRule(permission, pattern, ...rulesets)
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}

+ 9 - 0
packages/opencode/src/provider/provider.ts

@@ -184,6 +184,15 @@ export namespace Provider {
         options: {},
       }
     },
+    xai: async () => {
+      return {
+        autoload: false,
+        async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
+          return sdk.responses(modelID)
+        },
+        options: {},
+      }
+    },
     "github-copilot": async () => {
       return {
         autoload: false,

+ 1 - 1
packages/opencode/src/server/routes/project.ts

@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        const projects = await Project.list()
+        const projects = Project.list()
         return c.json(projects)
       },
     )

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

@@ -13,7 +13,7 @@ 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"
+import type { SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 

+ 12 - 10
packages/opencode/src/session/prompt.ts

@@ -28,11 +28,11 @@ import { MCP } from "../mcp"
 import { LSP } from "../lsp"
 import { ReadTool } from "../tool/read"
 import { FileTime } from "../file/time"
+import { NotFoundError } from "@/storage/db"
 import { Flag } from "../flag/flag"
 import { ulid } from "ulid"
 import { spawn } from "child_process"
 import { Command } from "../command"
-import { $ } from "bun"
 import { pathToFileURL, fileURLToPath } from "url"
 import { ConfigMarkdown } from "../config/markdown"
 import { SessionSummary } from "./summary"
@@ -48,6 +48,7 @@ import { iife } from "@/util/iife"
 import { Shell } from "@/shell/shell"
 import { Truncate } from "@/tool/truncate"
 import { decodeDataUrl } from "@/util/data-url"
+import { Process } from "@/util/process"
 
 // @ts-ignore
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1812,15 +1813,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       template = template + "\n\n" + input.arguments
     }
 
-    const shell = ConfigMarkdown.shell(template)
-    if (shell.length > 0) {
+    const shellMatches = ConfigMarkdown.shell(template)
+    if (shellMatches.length > 0) {
+      const sh = Shell.preferred()
       const results = await Promise.all(
-        shell.map(async ([, cmd]) => {
-          try {
-            return await $`${{ raw: cmd }}`.quiet().nothrow().text()
-          } catch (error) {
-            return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
-          }
+        shellMatches.map(async ([, cmd]) => {
+          const out = await Process.text([cmd], { shell: sh, nothrow: true })
+          return out.text
         }),
       )
       let index = 0
@@ -1990,7 +1989,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       if (!cleaned) return
 
       const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
-      return Session.setTitle({ sessionID: input.session.id, title })
+      return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
+        if (NotFoundError.isInstance(err)) return
+        throw err
+      })
     }
   }
 }

+ 14 - 6
packages/opencode/src/session/summary.ts

@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
 
 import { Storage } from "@/storage/storage"
 import { Bus } from "@/bus"
+import { NotFoundError } from "@/storage/db"
 
 export namespace SessionSummary {
   function unquoteGitPath(input: string) {
@@ -73,11 +74,17 @@ export namespace SessionSummary {
       messageID: MessageID.zod,
     }),
     async (input) => {
-      const all = await Session.messages({ sessionID: input.sessionID })
-      await Promise.all([
-        summarizeSession({ sessionID: input.sessionID, messages: all }),
-        summarizeMessage({ messageID: input.messageID, messages: all }),
-      ])
+      await Session.messages({ sessionID: input.sessionID })
+        .then((all) =>
+          Promise.all([
+            summarizeSession({ sessionID: input.sessionID, messages: all }),
+            summarizeMessage({ messageID: input.messageID, messages: all }),
+          ]),
+        )
+        .catch((err) => {
+          if (NotFoundError.isInstance(err)) return
+          throw err
+        })
     },
   )
 
@@ -102,7 +109,8 @@ export namespace SessionSummary {
     const messages = input.messages.filter(
       (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
     )
-    const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
+    const msgWithParts = messages.find((m) => m.info.id === input.messageID)
+    if (!msgWithParts) return
     const userMsg = msgWithParts.info as MessageV2.User
     const diffs = await computeDiff({ messages })
     userMsg.summary = {

+ 8 - 0
packages/opencode/src/storage/db.bun.ts

@@ -0,0 +1,8 @@
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+
+export function init(path: string) {
+  const sqlite = new Database(path, { create: true })
+  const db = drizzle({ client: sqlite })
+  return db
+}

+ 8 - 0
packages/opencode/src/storage/db.node.ts

@@ -0,0 +1,8 @@
+import { DatabaseSync } from "node:sqlite"
+import { drizzle } from "drizzle-orm/node-sqlite"
+
+export function init(path: string) {
+  const sqlite = new DatabaseSync(path)
+  const db = drizzle({ client: sqlite })
+  return db
+}

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

@@ -1,5 +1,4 @@
-import { Database as BunDatabase } from "bun:sqlite"
-import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
+import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
 import { migrate } from "drizzle-orm/bun-sqlite/migrator"
 import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
 export * from "drizzle-orm"
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
 import z from "zod"
 import path from "path"
 import { readFileSync, readdirSync, existsSync } from "fs"
-import * as schema from "./schema"
 import { Installation } from "../installation"
 import { Flag } from "../flag/flag"
 import { iife } from "@/util/iife"
+import { init } from "#db"
 
 declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
 
@@ -36,17 +35,12 @@ export namespace Database {
     return path.join(Global.Path.data, `opencode-${safe}.db`)
   })
 
-  type Schema = typeof schema
-  export type Transaction = SQLiteTransaction<"sync", void, Schema>
+  export type Transaction = SQLiteTransaction<"sync", void>
 
   type Client = SQLiteBunDatabase
 
   type Journal = { sql: string; timestamp: number; name: string }[]
 
-  const state = {
-    sqlite: undefined as BunDatabase | undefined,
-  }
-
   function time(tag: string) {
     const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
     if (!match) return 0
@@ -83,17 +77,14 @@ export namespace Database {
   export const Client = lazy(() => {
     log.info("opening database", { path: Path })
 
-    const sqlite = new BunDatabase(Path, { create: true })
-    state.sqlite = sqlite
-
-    sqlite.run("PRAGMA journal_mode = WAL")
-    sqlite.run("PRAGMA synchronous = NORMAL")
-    sqlite.run("PRAGMA busy_timeout = 5000")
-    sqlite.run("PRAGMA cache_size = -64000")
-    sqlite.run("PRAGMA foreign_keys = ON")
-    sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
+    const db = init(Path)
 
-    const db = drizzle({ client: sqlite })
+    db.run("PRAGMA journal_mode = WAL")
+    db.run("PRAGMA synchronous = NORMAL")
+    db.run("PRAGMA busy_timeout = 5000")
+    db.run("PRAGMA cache_size = -64000")
+    db.run("PRAGMA foreign_keys = ON")
+    db.run("PRAGMA wal_checkpoint(PASSIVE)")
 
     // Apply schema migrations
     const entries =
@@ -117,14 +108,11 @@ export namespace Database {
   })
 
   export function close() {
-    const sqlite = state.sqlite
-    if (!sqlite) return
-    sqlite.close()
-    state.sqlite = undefined
+    Client().$client.close()
     Client.reset()
   }
 
-  export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
+  export type TxOrDb = Transaction | Client
 
   const ctx = Context.create<{
     tx: TxOrDb

+ 1 - 1
packages/opencode/src/tool/registry.ts

@@ -46,7 +46,7 @@ export namespace ToolRegistry {
     if (matches.length) await Config.waitForDependencies()
     for (const match of matches) {
       const namespace = path.basename(match, path.extname(match))
-      const mod = await import(pathToFileURL(match).href)
+      const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
       for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
         custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
       }

+ 2 - 2
packages/opencode/src/tool/truncate-effect.ts

@@ -3,7 +3,7 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
 import path from "path"
 import type { Agent } from "../agent/agent"
 import { AppFileSystem } from "@/filesystem"
-import { PermissionNext } from "../permission"
+import { evaluate } from "@/permission/evaluate"
 import { Identifier } from "../id/id"
 import { Log } from "../util/log"
 import { ToolID } from "./schema"
@@ -28,7 +28,7 @@ export namespace TruncateEffect {
 
   function hasTaskTool(agent?: Agent.Info) {
     if (!agent?.permission) return false
-    return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
+    return evaluate("task", "*", agent.permission).action !== "deny"
   }
 
   export interface Interface {

+ 1 - 1
packages/opencode/src/util/process.ts

@@ -61,9 +61,9 @@ export namespace Process {
 
     const proc = launch(cmd[0], cmd.slice(1), {
       cwd: opts.cwd,
+      shell: opts.shell,
       env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
       stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
-      shell: opts.shell,
       windowsHide: process.platform === "win32",
     })
 

+ 5 - 1
packages/opencode/src/util/which.ts

@@ -1,9 +1,13 @@
 import whichPkg from "which"
+import path from "path"
+import { Global } from "../global"
 
 export function which(cmd: string, env?: NodeJS.ProcessEnv) {
+  const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
+  const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
   const result = whichPkg.sync(cmd, {
     nothrow: true,
-    path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
+    path: full,
     pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
   })
   return typeof result === "string" ? result : null

+ 10 - 0
packages/opencode/test/tool/truncation.test.ts

@@ -4,12 +4,14 @@ import { Effect, FileSystem, Layer } from "effect"
 import { Truncate } from "../../src/tool/truncate"
 import { TruncateEffect } from "../../src/tool/truncate-effect"
 import { Identifier } from "../../src/id/id"
+import { Process } from "../../src/util/process"
 import { Filesystem } from "../../src/util/filesystem"
 import path from "path"
 import { testEffect } from "../lib/effect"
 import { writeFileStringScoped } from "../lib/filesystem"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+const ROOT = path.resolve(import.meta.dir, "..", "..")
 
 describe("Truncate", () => {
   describe("output", () => {
@@ -125,6 +127,14 @@ describe("Truncate", () => {
       if (result.truncated) throw new Error("expected not truncated")
       expect("outputPath" in result).toBe(false)
     })
+
+    test("loads truncate effect in a fresh process", async () => {
+      const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
+        cwd: ROOT,
+      })
+
+      expect(out.code).toBe(0)
+    }, 20000)
   })
 
   describe("cleanup", () => {

+ 108 - 0
patches/@ai-sdk%[email protected]

@@ -0,0 +1,108 @@
+diff --git a/dist/index.mjs b/dist/index.mjs
+--- a/dist/index.mjs
++++ b/dist/index.mjs
+@@ -959,7 +959,7 @@
+   model: z4.string().nullish(),
+   object: z4.literal("response"),
+   output: z4.array(outputItemSchema),
+-  usage: xaiResponsesUsageSchema,
++  usage: xaiResponsesUsageSchema.nullish(),
+   status: z4.string()
+ });
+ var xaiResponsesChunkSchema = z4.union([
+\ No newline at end of file
+@@ -1143,6 +1143,18 @@
+   z4.object({
+     type: z4.literal("response.completed"),
+     response: xaiResponsesResponseSchema
++  }),
++  z4.object({
++    type: z4.literal("response.function_call_arguments.delta"),
++    item_id: z4.string(),
++    output_index: z4.number(),
++    delta: z4.string()
++  }),
++  z4.object({
++    type: z4.literal("response.function_call_arguments.done"),
++    item_id: z4.string(),
++    output_index: z4.number(),
++    arguments: z4.string()
+   })
+ ]);
+ 
+\ No newline at end of file
+@@ -1940,6 +1952,9 @@
+               if (response2.status) {
+                 finishReason = mapXaiResponsesFinishReason(response2.status);
+               }
++              if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
++                finishReason = "tool-calls";
++              }
+               return;
+             }
+             if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
+\ No newline at end of file
+@@ -2024,7 +2039,7 @@
+                   }
+                 }
+               } else if (part.type === "function_call") {
+-                if (!seenToolCalls.has(part.call_id)) {
++                if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
+                   seenToolCalls.add(part.call_id);
+                   controller.enqueue({
+                     type: "tool-input-start",
+\ No newline at end of file
+diff --git a/dist/index.js b/dist/index.js
+--- a/dist/index.js
++++ b/dist/index.js
+@@ -964,7 +964,7 @@
+   model: import_v44.z.string().nullish(),
+   object: import_v44.z.literal("response"),
+   output: import_v44.z.array(outputItemSchema),
+-  usage: xaiResponsesUsageSchema,
++  usage: xaiResponsesUsageSchema.nullish(),
+   status: import_v44.z.string()
+ });
+ var xaiResponsesChunkSchema = import_v44.z.union([
+\ No newline at end of file
+@@ -1148,6 +1148,18 @@
+   import_v44.z.object({
+     type: import_v44.z.literal("response.completed"),
+     response: xaiResponsesResponseSchema
++  }),
++  import_v44.z.object({
++    type: import_v44.z.literal("response.function_call_arguments.delta"),
++    item_id: import_v44.z.string(),
++    output_index: import_v44.z.number(),
++    delta: import_v44.z.string()
++  }),
++  import_v44.z.object({
++    type: import_v44.z.literal("response.function_call_arguments.done"),
++    item_id: import_v44.z.string(),
++    output_index: import_v44.z.number(),
++    arguments: import_v44.z.string()
+   })
+ ]);
+ 
+\ No newline at end of file
+@@ -1935,6 +1947,9 @@
+               if (response2.status) {
+                 finishReason = mapXaiResponsesFinishReason(response2.status);
+               }
++              if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
++                finishReason = "tool-calls";
++              }
+               return;
+             }
+             if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
+\ No newline at end of file
+@@ -2019,7 +2034,7 @@
+                   }
+                 }
+               } else if (part.type === "function_call") {
+-                if (!seenToolCalls.has(part.call_id)) {
++                if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
+                   seenToolCalls.add(part.call_id);
+                   controller.enqueue({
+                     type: "tool-input-start",
+\ No newline at end of file

+ 58 - 0
patches/[email protected]

@@ -0,0 +1,58 @@
+diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
+new file mode 100644
+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
+diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
+new file mode 100644
+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
+diff --git a/dist/dev.cjs b/dist/dev.cjs
+index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
+--- a/dist/dev.cjs
++++ b/dist/dev.cjs
+@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/dev.js b/dist/dev.js
+index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
+--- a/dist/dev.js
++++ b/dist/dev.js
+@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/solid.cjs b/dist/solid.cjs
+index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
+--- a/dist/solid.cjs
++++ b/dist/solid.cjs
+@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;
+diff --git a/dist/solid.js b/dist/solid.js
+index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
+--- a/dist/solid.js
++++ b/dist/solid.js
+@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
+     if (node.updatedAt != null && "observers" in node) {
+       writeSignal(node, nextValue, true);
+     } else if (Transition && Transition.running && node.pure) {
++      // On first computation during transition, also set committed value #2046
++      if (!Transition.sources.has(node)) node.value = nextValue;
+       Transition.sources.add(node);
+       node.tValue = nextValue;
+     } else node.value = nextValue;