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

Merge remote-tracking branch 'origin/dev' into opencode-remote-voice

Ryan Vogel пре 5 дана
родитељ
комит
0114a3f317
100 измењених фајлова са 1980 додато и 1690 уклоњено
  1. 1 0
      .github/VOUCHED.td
  2. 2 1
      .github/workflows/publish.yml
  3. 13 0
      .github/workflows/test.yml
  4. 84 54
      bun.lock
  5. 4 4
      nix/hashes.json
  6. 5 4
      package.json
  7. 7 3
      packages/app/e2e/backend.ts
  8. 1 1
      packages/app/package.json
  9. 0 1
      packages/app/src/app.tsx
  10. 7 2
      packages/app/src/components/terminal.tsx
  11. 3 2
      packages/app/src/context/global-sync/event-reducer.ts
  12. 3 2
      packages/app/src/context/sync.tsx
  13. 7 10
      packages/app/src/pages/session.tsx
  14. 74 0
      packages/app/src/utils/diffs.test.ts
  15. 49 0
      packages/app/src/utils/diffs.ts
  16. 1 1
      packages/console/app/package.json
  17. 2 1
      packages/console/app/src/routes/download/index.css
  18. 1 1
      packages/console/core/package.json
  19. 1 1
      packages/console/function/package.json
  20. 1 1
      packages/console/mail/package.json
  21. 0 5
      packages/desktop-electron/electron-builder.config.ts
  22. 31 0
      packages/desktop-electron/electron.vite.config.ts
  23. 23 12
      packages/desktop-electron/package.json
  24. 9 0
      packages/desktop-electron/scripts/prebuild.ts
  25. 1 13
      packages/desktop-electron/scripts/predev.ts
  26. 1 17
      packages/desktop-electron/scripts/prepare.ts
  27. 0 283
      packages/desktop-electron/src/main/cli.ts
  28. 22 0
      packages/desktop-electron/src/main/env.d.ts
  29. 10 22
      packages/desktop-electron/src/main/index.ts
  30. 0 2
      packages/desktop-electron/src/main/ipc.ts
  31. 0 5
      packages/desktop-electron/src/main/menu.ts
  32. 30 16
      packages/desktop-electron/src/main/server.ts
  33. 13 13
      packages/desktop-electron/src/main/shell-env.ts
  34. 1 1
      packages/desktop/package.json
  35. 5 2
      packages/desktop/scripts/finalize-latest-json.ts
  36. 1 1
      packages/enterprise/package.json
  37. 6 6
      packages/extensions/zed/extension.toml
  38. 1 1
      packages/function/package.json
  39. 12 15
      packages/opencode/package.json
  40. 7 16
      packages/opencode/script/build-node.ts
  41. 2 15
      packages/opencode/script/build.ts
  42. 23 0
      packages/opencode/script/generate.ts
  43. 87 10
      packages/opencode/specs/effect-migration.md
  44. 1 4
      packages/opencode/specs/tui-plugins.md
  45. 2 34
      packages/opencode/src/account/index.ts
  46. 2 2
      packages/opencode/src/account/repo.ts
  47. 6 26
      packages/opencode/src/account/schema.ts
  48. 16 11
      packages/opencode/src/agent/agent.ts
  49. 2 2
      packages/opencode/src/auth/index.ts
  50. 2 0
      packages/opencode/src/bus/global.ts
  51. 12 4
      packages/opencode/src/bus/index.ts
  52. 6 5
      packages/opencode/src/cli/cmd/account.ts
  53. 2 1
      packages/opencode/src/cli/cmd/db.ts
  54. 10 7
      packages/opencode/src/cli/cmd/debug/agent.ts
  55. 10 5
      packages/opencode/src/cli/cmd/github.ts
  56. 3 2
      packages/opencode/src/cli/cmd/import.ts
  57. 1 0
      packages/opencode/src/cli/cmd/mcp.ts
  58. 18 7
      packages/opencode/src/cli/cmd/pr.ts
  59. 2 2
      packages/opencode/src/cli/cmd/run.ts
  60. 33 49
      packages/opencode/src/cli/cmd/tui/app.tsx
  61. 99 0
      packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx
  62. 74 6
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  63. 121 0
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  64. 0 320
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
  65. 1 1
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  66. 15 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  67. 0 151
      packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx
  68. 3 1
      packages/opencode/src/cli/cmd/tui/context/directory.ts
  69. 41 0
      packages/opencode/src/cli/cmd/tui/context/event.ts
  70. 106 0
      packages/opencode/src/cli/cmd/tui/context/project.tsx
  71. 0 1
      packages/opencode/src/cli/cmd/tui/context/route.tsx
  72. 9 8
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  73. 45 45
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  74. 4 10
      packages/opencode/src/cli/cmd/tui/plugin/api.tsx
  75. 10 3
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  76. 38 8
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  77. 12 14
      packages/opencode/src/cli/cmd/tui/thread.ts
  78. 42 11
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  79. 2 96
      packages/opencode/src/cli/cmd/tui/worker.ts
  80. 2 1
      packages/opencode/src/cli/cmd/uninstall.ts
  81. 8 3
      packages/opencode/src/cli/cmd/upgrade.ts
  82. 4 3
      packages/opencode/src/cli/upgrade.ts
  83. 6 10
      packages/opencode/src/command/index.ts
  84. 102 38
      packages/opencode/src/config/config.ts
  85. 1 2
      packages/opencode/src/control-plane/schema.ts
  86. 22 0
      packages/opencode/src/control-plane/workspace-context.ts
  87. 102 41
      packages/opencode/src/control-plane/workspace.ts
  88. 100 0
      packages/opencode/src/effect/app-runtime.ts
  89. 10 0
      packages/opencode/src/effect/bootstrap-runtime.ts
  90. 13 1
      packages/opencode/src/effect/cross-spawn-spawner.ts
  91. 6 2
      packages/opencode/src/effect/instance-ref.ts
  92. 14 12
      packages/opencode/src/effect/instance-state.ts
  93. 67 0
      packages/opencode/src/effect/logger.ts
  94. 32 25
      packages/opencode/src/effect/oltp.ts
  95. 9 7
      packages/opencode/src/effect/run-service.ts
  96. 6 14
      packages/opencode/src/effect/runner.ts
  97. 92 109
      packages/opencode/src/file/index.ts
  98. 70 0
      packages/opencode/src/file/ripgrep.ts
  99. 6 25
      packages/opencode/src/file/time.ts
  100. 7 15
      packages/opencode/src/file/watcher.ts

+ 1 - 0
.github/VOUCHED.td

@@ -26,6 +26,7 @@ kommander
 r44vc0rp
 rekram1-node
 -robinmordasiewicz
+simonklee
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
 -toastythebot

+ 2 - 1
.github/workflows/publish.yml

@@ -389,6 +389,7 @@ jobs:
     needs:
       - build-cli
       - version
+    if: github.repository == 'anomalyco/opencode'
     continue-on-error: false
     env:
       AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -421,7 +422,6 @@ jobs:
             target: aarch64-unknown-linux-gnu
             platform_flag: --linux
     runs-on: ${{ matrix.settings.host }}
-    # if: github.ref_name == 'beta'
     steps:
       - uses: actions/checkout@v3
 
@@ -547,6 +547,7 @@ jobs:
       - sign-cli-windows
       - build-tauri
       - build-electron
+    if: always() && !failure() && !cancelled()
     runs-on: blacksmith-4vcpu-ubuntu-2404
     steps:
       - uses: actions/checkout@v3

+ 13 - 0
.github/workflows/test.yml

@@ -17,6 +17,9 @@ permissions:
   contents: read
   checks: write
 
+env:
+  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
 jobs:
   unit:
     name: unit (${{ matrix.settings.name }})
@@ -38,6 +41,11 @@ jobs:
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
 
+      - name: Setup Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+
       - name: Setup Bun
         uses: ./.github/actions/setup-bun
 
@@ -102,6 +110,11 @@ jobs:
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
 
+      - name: Setup Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+
       - name: Setup Bun
         uses: ./.github/actions/setup-bun
 

+ 84 - 54
bun.lock

@@ -45,7 +45,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -99,7 +99,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -133,7 +133,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -160,7 +160,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/openai": "3.0.48",
@@ -184,7 +184,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -208,7 +208,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -241,14 +241,8 @@
     },
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
-        "@opencode-ai/app": "workspace:*",
-        "@opencode-ai/ui": "workspace:*",
-        "@solid-primitives/i18n": "2.2.1",
-        "@solid-primitives/storage": "catalog:",
-        "@solidjs/meta": "catalog:",
-        "@solidjs/router": "0.15.4",
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
         "electron-log": "^5",
@@ -256,24 +250,41 @@
         "electron-updater": "^6",
         "electron-window-state": "^5.0.3",
         "marked": "^15",
-        "solid-js": "catalog:",
-        "tree-kill": "^1.2.2",
       },
       "devDependencies": {
         "@actions/artifact": "4.0.0",
+        "@lydell/node-pty": "catalog:",
+        "@opencode-ai/app": "workspace:*",
+        "@opencode-ai/ui": "workspace:*",
+        "@solid-primitives/i18n": "2.2.1",
+        "@solid-primitives/storage": "catalog:",
+        "@solidjs/meta": "catalog:",
+        "@solidjs/router": "0.15.4",
         "@types/bun": "catalog:",
         "@types/node": "catalog:",
         "@typescript/native-preview": "catalog:",
+        "@valibot/to-json-schema": "1.6.0",
         "electron": "40.4.1",
         "electron-builder": "^26",
         "electron-vite": "^5",
+        "solid-js": "catalog:",
+        "sury": "11.0.0-alpha.4",
         "typescript": "~5.6.2",
         "vite": "catalog:",
+        "zod-openapi": "5.4.6",
+      },
+      "optionalDependencies": {
+        "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
+        "@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
+        "@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
+        "@lydell/node-pty-linux-x64": "1.2.0-beta.10",
+        "@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
+        "@lydell/node-pty-win32-x64": "1.2.0-beta.10",
       },
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -302,7 +313,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -370,7 +381,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -378,7 +389,7 @@
         "@actions/core": "1.11.1",
         "@actions/github": "6.0.1",
         "@agentclientprotocol/sdk": "0.16.1",
-        "@ai-sdk/amazon-bedrock": "4.0.83",
+        "@ai-sdk/amazon-bedrock": "4.0.93",
         "@ai-sdk/anthropic": "3.0.67",
         "@ai-sdk/azure": "3.0.49",
         "@ai-sdk/cerebras": "2.0.41",
@@ -390,7 +401,7 @@
         "@ai-sdk/groq": "3.0.31",
         "@ai-sdk/mistral": "3.0.27",
         "@ai-sdk/openai": "3.0.48",
-        "@ai-sdk/openai-compatible": "2.0.37",
+        "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
         "@ai-sdk/provider": "3.0.8",
         "@ai-sdk/provider-utils": "4.0.23",
@@ -400,13 +411,12 @@
         "@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/node-server": "1.19.11",
         "@hono/node-ws": "1.3.0",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
-        "@lydell/node-pty": "1.2.0-beta.10",
+        "@lydell/node-pty": "catalog:",
         "@modelcontextprotocol/sdk": "1.27.1",
         "@npmcli/arborist": "9.4.0",
         "@octokit/graphql": "9.0.2",
@@ -416,7 +426,7 @@
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
-        "@openrouter/ai-sdk-provider": "2.4.2",
+        "@openrouter/ai-sdk-provider": "2.5.1",
         "@opentui/core": "0.1.97",
         "@opentui/solid": "0.1.97",
         "@parcel/watcher": "2.5.1",
@@ -437,7 +447,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.0.0",
+        "gitlab-ai-provider": "6.4.2",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -473,7 +483,7 @@
       },
       "devDependencies": {
         "@babel/core": "7.28.4",
-        "@effect/language-service": "0.79.0",
+        "@effect/language-service": "0.84.2",
         "@octokit/webhooks-types": "7.6.1",
         "@opencode-ai/script": "workspace:*",
         "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -508,9 +518,10 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
+        "effect": "catalog:",
         "zod": "catalog:",
       },
       "devDependencies": {
@@ -542,7 +553,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -557,7 +568,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -592,7 +603,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -641,7 +652,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -652,7 +663,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.4.0",
+      "version": "1.4.3",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -702,9 +713,10 @@
   },
   "catalog": {
     "@cloudflare/workers-types": "4.20251008.0",
-    "@effect/platform-node": "4.0.0-beta.43",
+    "@effect/platform-node": "4.0.0-beta.46",
     "@hono/zod-validator": "0.4.2",
     "@kobalte/core": "0.13.11",
+    "@lydell/node-pty": "1.2.0-beta.10",
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
     "@pierre/diffs": "1.1.0-beta.18",
@@ -722,13 +734,13 @@
     "@types/node": "22.13.9",
     "@types/semver": "7.7.1",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
-    "ai": "6.0.149",
+    "ai": "6.0.158",
     "cross-spawn": "7.0.6",
     "diff": "8.0.2",
     "dompurify": "3.3.1",
     "drizzle-kit": "1.0.0-beta.19-d95b7a4",
     "drizzle-orm": "1.0.0-beta.19-d95b7a4",
-    "effect": "4.0.0-beta.43",
+    "effect": "4.0.0-beta.46",
     "fuzzysort": "3.1.0",
     "hono": "4.10.7",
     "hono-openapi": "1.1.2",
@@ -767,7 +779,7 @@
 
     "@agentclientprotocol/sdk": ["@agentclientprotocol/[email protected]", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
 
-    "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected].83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
+    "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected].93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
 
     "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
 
@@ -1205,11 +1217,11 @@
 
     "@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
 
-    "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
+    "@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
 
-    "@effect/platform-node": ["@effect/[email protected]3", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
+    "@effect/platform-node": ["@effect/[email protected]6", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="],
 
-    "@effect/platform-node-shared": ["@effect/[email protected]3", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
+    "@effect/platform-node-shared": ["@effect/[email protected]6", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="],
 
     "@egjs/hammerjs": ["@egjs/[email protected]", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
 
@@ -1425,8 +1437,6 @@
 
     "@gar/promise-retry": ["@gar/[email protected]", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
 
-    "@gitlab/gitlab-ai-provider": ["@gitlab/[email protected]", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
-
     "@gitlab/opencode-gitlab-auth": ["@gitlab/[email protected]", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
 
     "@graphql-typed-document-node/core": ["@graphql-typed-document-node/[email protected]", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1831,7 +1841,7 @@
 
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
 
-    "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="],
+    "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
@@ -2701,6 +2711,8 @@
 
     "@ungap/structured-clone": ["@ungap/[email protected]", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
 
+    "@valibot/to-json-schema": ["@valibot/[email protected]", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
+
     "@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
 
     "@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2759,7 +2771,7 @@
 
     "agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
 
-    "ai": ["[email protected]49", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="],
+    "ai": ["[email protected]58", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
 
     "ai-gateway-provider": ["[email protected]", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
 
@@ -3337,7 +3349,7 @@
 
     "ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
 
-    "effect": ["[email protected]3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
+    "effect": ["[email protected]6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="],
 
     "ejs": ["[email protected]", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
 
@@ -3743,7 +3755,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
+    "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
@@ -5359,6 +5371,8 @@
 
     "supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
 
+    "sury": ["[email protected]", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
+
     "system-architecture": ["[email protected]", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
 
     "tagged-tag": ["[email protected]", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5449,8 +5463,6 @@
 
     "traverse": ["[email protected]", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
 
-    "tree-kill": ["[email protected]", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
-
     "tree-sitter-bash": ["[email protected]", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
 
     "tree-sitter-powershell": ["[email protected]", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
@@ -5625,6 +5637,8 @@
 
     "uuid": ["[email protected]", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
 
+    "valibot": ["[email protected]", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
+
     "validate-npm-package-name": ["[email protected]", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
 
     "vary": ["[email protected]", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5801,6 +5815,8 @@
 
     "zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
 
+    "zod-openapi": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
+
     "zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
 
     "zod-to-ts": ["[email protected]", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -5825,7 +5841,11 @@
 
     "@actions/http-client/undici": ["[email protected]", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
 
-    "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+    "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
+
+    "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
+
+    "@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
 
     "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
 
@@ -6169,10 +6189,6 @@
 
     "@fastify/proxy-addr/ipaddr.js": ["[email protected]", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
 
-    "@gitlab/gitlab-ai-provider/openai": ["[email protected]", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
-
-    "@gitlab/gitlab-ai-provider/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
     "@gitlab/opencode-gitlab-auth/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
 
     "@hey-api/openapi-ts/open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -6501,6 +6517,10 @@
 
     "@solidjs/start/vite-plugin-solid": ["[email protected]", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="],
 
+    "@standard-community/standard-json/effect": ["[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
+
+    "@standard-community/standard-openapi/effect": ["[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
+
     "@storybook/addon-docs/react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
 
     "@tailwindcss/oxide/detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6547,7 +6567,9 @@
 
     "accepts/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
 
-    "ai/@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="],
+    "ai/@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
+
+    "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
 
     "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/[email protected]", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
 
@@ -6873,6 +6895,8 @@
 
     "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
 
+    "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
+
     "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
 
     "opencontrol/@tsconfig/bun": ["@tsconfig/[email protected]", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -7105,8 +7129,6 @@
 
     "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
 
-    "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
     "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
 
     "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -7719,6 +7741,10 @@
 
     "@solidjs/start/shiki/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
 
+    "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+    "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "@storybook/addon-docs/react-dom/scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
 
     "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -7733,6 +7759,8 @@
 
     "accepts/mime-types/mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
 
+    "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+
     "ajv-keywords/ajv/json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
 
     "ansi-align/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -8221,6 +8249,8 @@
 
     "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["[email protected]", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
 
+    "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
     "ansi-align/string-width/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
 
     "app-builder-lib/@electron/get/fs-extra/universalify": ["[email protected]", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=",
-    "aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=",
-    "aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=",
-    "x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w="
+    "x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
+    "aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
+    "aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
+    "x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
   }
 }

+ 5 - 4
package.json

@@ -26,7 +26,7 @@
       "packages/slack"
     ],
     "catalog": {
-      "@effect/platform-node": "4.0.0-beta.43",
+      "@effect/platform-node": "4.0.0-beta.46",
       "@types/bun": "1.3.11",
       "@types/cross-spawn": "6.0.6",
       "@octokit/rest": "22.0.0",
@@ -47,8 +47,8 @@
       "dompurify": "3.3.1",
       "drizzle-kit": "1.0.0-beta.19-d95b7a4",
       "drizzle-orm": "1.0.0-beta.19-d95b7a4",
-      "effect": "4.0.0-beta.43",
-      "ai": "6.0.149",
+      "effect": "4.0.0-beta.46",
+      "ai": "6.0.158",
       "cross-spawn": "7.0.6",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",
@@ -71,7 +71,8 @@
       "@solidjs/router": "0.15.4",
       "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
       "solid-js": "1.9.10",
-      "vite-plugin-solid": "2.11.10"
+      "vite-plugin-solid": "2.11.10",
+      "@lydell/node-pty": "1.2.0-beta.10"
     }
   },
   "devDependencies": {

+ 7 - 3
packages/app/e2e/backend.ts

@@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") {
   throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
 }
 
+function done(proc: ReturnType<typeof spawn>) {
+  return proc.exitCode !== null || proc.signalCode !== null
+}
+
 async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
-  if (proc.exitCode !== null) return
+  if (done(proc)) return
   await Promise.race([
     new Promise<void>((resolve) => proc.once("exit", () => resolve())),
     new Promise<void>((resolve) => setTimeout(resolve, timeout)),
@@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
   return {
     url,
     async stop() {
-      if (proc.exitCode === null) {
+      if (!done(proc)) {
         proc.kill("SIGTERM")
         await waitExit(proc)
       }
-      if (proc.exitCode === null) {
+      if (!done(proc)) {
         proc.kill("SIGKILL")
         await waitExit(proc)
       }

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "description": "",
   "type": "module",
   "exports": {

+ 0 - 1
packages/app/src/app.tsx

@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
             if (checkMode() === "background" || type === "http") return false
           }
         }).pipe(
-          effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
           Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
           Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
           Effect.runPromise,

+ 7 - 2
packages/app/src/components/terminal.tsx

@@ -174,6 +174,7 @@ export const Terminal = (props: TerminalProps) => {
   const auth = server.current?.http
   const username = auth?.username ?? "opencode"
   const password = auth?.password ?? ""
+  const sameOrigin = new URL(url, location.href).origin === location.origin
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
   const id = local.pty.id
@@ -519,8 +520,12 @@ export const Terminal = (props: TerminalProps) => {
         next.searchParams.set("directory", directory)
         next.searchParams.set("cursor", String(seek))
         next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
-        next.username = username
-        next.password = password
+        if (!sameOrigin && password) {
+          next.searchParams.set("auth_token", btoa(`${username}:${password}`))
+          // For same-origin requests, let the browser reuse the page's existing auth.
+          next.username = username
+          next.password = password
+        }
 
         const socket = new WebSocket(next)
         socket.binaryType = "arraybuffer"

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

@@ -14,6 +14,7 @@ import type {
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
+import { diffs as list, message as clean } from "@/utils/diffs"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 
@@ -162,7 +163,7 @@ export function applyDirectoryEvent(input: {
     }
     case "session.diff": {
       const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
-      input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+      input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
       break
     }
     case "todo.updated": {
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "message.updated": {
-      const info = (event.properties as { info: Message }).info
+      const info = clean((event.properties as { info: Message }).info)
       const messages = input.store.message[info.sessionID]
       if (!messages) {
         input.setStore("message", info.sessionID, [info])

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

@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
+import { diffs as list, message as clean } from "@/utils/diffs"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
       )
       const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
-      const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
+      const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
       const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
       const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
       return {
@@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           return runInflight(inflightDiff, key, () =>
             retry(() => client.session.diff({ sessionID })).then((diff) => {
               if (!tracked(directory, sessionID)) return
-              setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
+              setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
             }),
           )
         },

+ 7 - 10
packages/app/src/pages/session.tsx

@@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { Identifier } from "@/utils/id"
+import { diffs as list } from "@/utils/diffs"
 import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { same } from "@/utils/same"
@@ -430,7 +431,7 @@ export default function Page() {
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const isChildSession = createMemo(() => !!info()?.parentID)
-  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+  const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
   const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
   const hasSessionReview = createMemo(() => sessionCount() > 0)
   const canReview = createMemo(() => !!sync.project)
@@ -611,7 +612,7 @@ export default function Page() {
       .diff({ mode })
       .then((result) => {
         if (vcsRun.get(mode) !== run) return
-        setVcs("diff", mode, result.data ?? [])
+        setVcs("diff", mode, list(result.data))
         setVcs("ready", mode, true)
       })
       .catch((error) => {
@@ -649,7 +650,7 @@ export default function Page() {
     return open
   }, desktopReviewOpen())
 
-  const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
+  const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
   const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
   const changesOptions = createMemo<ChangeMode[]>(() => {
     const list: ChangeMode[] = []
@@ -669,15 +670,11 @@ export default function Page() {
     if (store.changes === "git" || store.changes === "branch") return store.changes
   })
   const reviewDiffs = createMemo(() => {
-    if (store.changes === "git") return vcs.diff.git
-    if (store.changes === "branch") return vcs.diff.branch
+    if (store.changes === "git") return list(vcs.diff.git)
+    if (store.changes === "branch") return list(vcs.diff.branch)
     return turnDiffs()
   })
-  const reviewCount = createMemo(() => {
-    if (store.changes === "git") return vcs.diff.git.length
-    if (store.changes === "branch") return vcs.diff.branch.length
-    return turnDiffs().length
-  })
+  const reviewCount = createMemo(() => reviewDiffs().length)
   const hasReview = createMemo(() => reviewCount() > 0)
   const reviewReady = createMemo(() => {
     if (store.changes === "git") return vcs.ready.git

+ 74 - 0
packages/app/src/utils/diffs.test.ts

@@ -0,0 +1,74 @@
+import { describe, expect, test } from "bun:test"
+import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+import { diffs, message } from "./diffs"
+
+const item = {
+  file: "src/app.ts",
+  patch: "@@ -1 +1 @@\n-old\n+new\n",
+  additions: 1,
+  deletions: 1,
+  status: "modified",
+} satisfies SnapshotFileDiff
+
+describe("diffs", () => {
+  test("keeps valid arrays", () => {
+    expect(diffs([item])).toEqual([item])
+  })
+
+  test("wraps a single diff object", () => {
+    expect(diffs(item)).toEqual([item])
+  })
+
+  test("reads keyed diff objects", () => {
+    expect(diffs({ a: item })).toEqual([item])
+  })
+
+  test("drops invalid entries", () => {
+    expect(
+      diffs([
+        item,
+        { file: "src/bad.ts", additions: 1, deletions: 1 },
+        { patch: item.patch, additions: 1, deletions: 1 },
+      ]),
+    ).toEqual([item])
+  })
+})
+
+describe("message", () => {
+  test("normalizes user summaries with object diffs", () => {
+    const input = {
+      id: "msg_1",
+      sessionID: "ses_1",
+      role: "user",
+      time: { created: 1 },
+      agent: "build",
+      model: { providerID: "openai", modelID: "gpt-5" },
+      summary: {
+        title: "Edit",
+        diffs: { a: item },
+      },
+    } as unknown as Message
+
+    expect(message(input)).toMatchObject({
+      summary: {
+        title: "Edit",
+        diffs: [item],
+      },
+    })
+  })
+
+  test("drops invalid user summaries", () => {
+    const input = {
+      id: "msg_1",
+      sessionID: "ses_1",
+      role: "user",
+      time: { created: 1 },
+      agent: "build",
+      model: { providerID: "openai", modelID: "gpt-5" },
+      summary: true,
+    } as unknown as Message
+
+    expect(message(input)).toMatchObject({ summary: undefined })
+  })
+})

+ 49 - 0
packages/app/src/utils/diffs.ts

@@ -0,0 +1,49 @@
+import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+
+type Diff = SnapshotFileDiff | VcsFileDiff
+
+function diff(value: unknown): value is Diff {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return false
+  if (!("file" in value) || typeof value.file !== "string") return false
+  if (!("patch" in value) || typeof value.patch !== "string") return false
+  if (!("additions" in value) || typeof value.additions !== "number") return false
+  if (!("deletions" in value) || typeof value.deletions !== "number") return false
+  if (!("status" in value) || value.status === undefined) return true
+  return value.status === "added" || value.status === "deleted" || value.status === "modified"
+}
+
+function object(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
+export function diffs(value: unknown): Diff[] {
+  if (Array.isArray(value) && value.every(diff)) return value
+  if (Array.isArray(value)) return value.filter(diff)
+  if (diff(value)) return [value]
+  if (!object(value)) return []
+  return Object.values(value).filter(diff)
+}
+
+export function message(value: Message): Message {
+  if (value.role !== "user") return value
+
+  const raw = value.summary as unknown
+  if (raw === undefined) return value
+  if (!object(raw)) return { ...value, summary: undefined }
+
+  const title = typeof raw.title === "string" ? raw.title : undefined
+  const body = typeof raw.body === "string" ? raw.body : undefined
+  const next = diffs(raw.diffs)
+
+  if (title === raw.title && body === raw.body && next === raw.diffs) return value
+
+  return {
+    ...value,
+    summary: {
+      ...(title === undefined ? {} : { title }),
+      ...(body === undefined ? {} : { body }),
+      diffs: next,
+    },
+  }
+}

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

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

+ 2 - 1
packages/console/app/src/routes/download/index.css

@@ -316,7 +316,8 @@
 
   /* Download Hero Section */
   [data-component="download-hero"] {
-    display: grid;
+    /* display: grid; */
+    display: none;
     grid-template-columns: 260px 1fr;
     gap: 4rem;
     padding-bottom: 2rem;

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

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "private": true,
   "type": "module",
   "license": "MIT",

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

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

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

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

+ 0 - 5
packages/desktop-electron/electron-builder.config.ts

@@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
   },
   files: ["out/**/*", "resources/**/*"],
   extraResources: [
-    {
-      from: "resources/",
-      to: "",
-      filter: ["opencode-cli*"],
-    },
     {
       from: "native/",
       to: "native/",

+ 31 - 0
packages/desktop-electron/electron.vite.config.ts

@@ -1,5 +1,6 @@
 import { defineConfig } from "electron-vite"
 import appPlugin from "@opencode-ai/app/vite"
+import * as fs from "node:fs/promises"
 
 const channel = (() => {
   const raw = process.env.OPENCODE_CHANNEL
@@ -7,6 +8,10 @@ const channel = (() => {
   return "dev"
 })()
 
+const OPENCODE_SERVER_DIST = "../opencode/dist/node"
+
+const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
+
 export default defineConfig({
   main: {
     define: {
@@ -16,7 +21,33 @@ export default defineConfig({
       rollupOptions: {
         input: { index: "src/main/index.ts" },
       },
+      externalizeDeps: { include: [nodePtyPkg] },
     },
+    plugins: [
+      {
+        name: "opencode:node-pty-narrower",
+        enforce: "pre",
+        resolveId(s) {
+          if (s === "@lydell/node-pty") return nodePtyPkg
+        },
+      },
+      {
+        name: "opencode:virtual-server-module",
+        enforce: "pre",
+        resolveId(id) {
+          if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
+        },
+      },
+      {
+        name: "opencode:copy-server-assets",
+        async writeBundle() {
+          for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
+            if (!l.endsWith(".wasm")) continue
+            await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
+          }
+        },
+      },
+    ],
   },
   preload: {
     build: {

+ 23 - 12
packages/desktop-electron/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop-electron",
   "private": true,
-  "version": "1.4.0",
+  "version": "1.4.3",
   "type": "module",
   "license": "MIT",
   "homepage": "https://opencode.ai",
@@ -13,7 +13,7 @@
     "typecheck": "tsgo -b",
     "predev": "bun ./scripts/predev.ts",
     "dev": "electron-vite dev",
-    "prebuild": "bun ./scripts/copy-icons.ts",
+    "prebuild": "bun ./scripts/prebuild.ts",
     "build": "electron-vite build",
     "preview": "electron-vite preview",
     "package": "electron-builder --config electron-builder.config.ts",
@@ -24,31 +24,42 @@
   },
   "main": "./out/main/index.js",
   "dependencies": {
-    "@opencode-ai/app": "workspace:*",
-    "@opencode-ai/ui": "workspace:*",
-    "@solid-primitives/i18n": "2.2.1",
-    "@solid-primitives/storage": "catalog:",
-    "@solidjs/meta": "catalog:",
-    "@solidjs/router": "0.15.4",
     "effect": "catalog:",
     "electron-context-menu": "4.1.2",
     "electron-log": "^5",
     "electron-store": "^10",
     "electron-updater": "^6",
     "electron-window-state": "^5.0.3",
-    "marked": "^15",
-    "solid-js": "catalog:",
-    "tree-kill": "^1.2.2"
+    "marked": "^15"
   },
   "devDependencies": {
     "@actions/artifact": "4.0.0",
+    "@lydell/node-pty": "catalog:",
+    "@opencode-ai/app": "workspace:*",
+    "@opencode-ai/ui": "workspace:*",
+    "@solid-primitives/i18n": "2.2.1",
+    "@solid-primitives/storage": "catalog:",
+    "@solidjs/meta": "catalog:",
+    "@solidjs/router": "0.15.4",
     "@types/bun": "catalog:",
     "@types/node": "catalog:",
     "@typescript/native-preview": "catalog:",
+    "@valibot/to-json-schema": "1.6.0",
     "electron": "40.4.1",
     "electron-builder": "^26",
     "electron-vite": "^5",
+    "solid-js": "catalog:",
+    "sury": "11.0.0-alpha.4",
     "typescript": "~5.6.2",
-    "vite": "catalog:"
+    "vite": "catalog:",
+    "zod-openapi": "5.4.6"
+  },
+  "optionalDependencies": {
+    "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
+    "@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
+    "@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
+    "@lydell/node-pty-linux-x64": "1.2.0-beta.10",
+    "@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
+    "@lydell/node-pty-win32-x64": "1.2.0-beta.10"
   }
 }

+ 9 - 0
packages/desktop-electron/scripts/prebuild.ts

@@ -0,0 +1,9 @@
+#!/usr/bin/env bun
+import { $ } from "bun"
+
+import { resolveChannel } from "./utils"
+
+const channel = resolveChannel()
+await $`bun ./scripts/copy-icons.ts ${channel}`
+
+await $`cd ../opencode && bun script/build-node.ts`

+ 1 - 13
packages/desktop-electron/scripts/predev.ts

@@ -1,17 +1,5 @@
 import { $ } from "bun"
 
-import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
-
 await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
 
-const RUST_TARGET = Bun.env.RUST_TARGET
-
-const sidecarConfig = getCurrentSidecar(RUST_TARGET)
-
-const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
-
-await (sidecarConfig.ocBinary.includes("-baseline")
-  ? $`cd ../opencode && bun run build --single --baseline`
-  : $`cd ../opencode && bun run build --single`)
-
-await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
+await $`cd ../opencode && bun script/build-node.ts`

+ 1 - 17
packages/desktop-electron/scripts/prepare.ts

@@ -1,25 +1,9 @@
 #!/usr/bin/env bun
-import { $ } from "bun"
-
 import { Script } from "@opencode-ai/script"
-import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
 
-const channel = resolveChannel()
-await $`bun ./scripts/copy-icons.ts ${channel}`
+await import("./prebuild")
 
 const pkg = await Bun.file("./package.json").json()
 pkg.version = Script.version
 await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
 console.log(`Updated package.json version to ${Script.version}`)
-
-const sidecarConfig = getCurrentSidecar()
-const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
-
-const dir = "resources/opencode-binaries"
-
-await $`mkdir -p ${dir}`
-await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
-
-await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
-
-await $`rm -rf ${dir}`

+ 0 - 283
packages/desktop-electron/src/main/cli.ts

@@ -1,283 +0,0 @@
-import { execFileSync, spawn } from "node:child_process"
-import { EventEmitter } from "node:events"
-import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
-import { tmpdir } from "node:os"
-import { dirname, join } from "node:path"
-import readline from "node:readline"
-import { fileURLToPath } from "node:url"
-import { app } from "electron"
-import treeKill from "tree-kill"
-
-import { WSL_ENABLED_KEY } from "./constants"
-import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
-import { store } from "./store"
-
-const CLI_INSTALL_DIR = ".opencode/bin"
-const CLI_BINARY_NAME = "opencode"
-
-export type ServerConfig = {
-  hostname?: string
-  port?: number
-}
-
-export type Config = {
-  server?: ServerConfig
-}
-
-export type TerminatedPayload = { code: number | null; signal: number | null }
-
-export type CommandEvent =
-  | { type: "stdout"; value: string }
-  | { type: "stderr"; value: string }
-  | { type: "error"; value: string }
-  | { type: "terminated"; value: TerminatedPayload }
-  | { type: "sqlite"; value: SqliteMigrationProgress }
-
-export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
-
-export type CommandChild = {
-  pid: number | undefined
-  kill: () => void
-}
-
-const root = dirname(fileURLToPath(import.meta.url))
-
-export function getSidecarPath() {
-  const suffix = process.platform === "win32" ? ".exe" : ""
-  const path = app.isPackaged
-    ? join(process.resourcesPath, `opencode-cli${suffix}`)
-    : join(root, "../../resources", `opencode-cli${suffix}`)
-  console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
-  return path
-}
-
-export async function getConfig(): Promise<Config | null> {
-  const { events } = spawnCommand("debug config", {})
-  let output = ""
-
-  await new Promise<void>((resolve) => {
-    events.on("stdout", (line: string) => {
-      output += line
-    })
-    events.on("stderr", (line: string) => {
-      output += line
-    })
-    events.on("terminated", () => resolve())
-    events.on("error", () => resolve())
-  })
-
-  try {
-    return JSON.parse(output) as Config
-  } catch {
-    return null
-  }
-}
-
-export async function installCli(): Promise<string> {
-  if (process.platform === "win32") {
-    throw new Error("CLI installation is only supported on macOS & Linux")
-  }
-
-  const sidecar = getSidecarPath()
-  const scriptPath = join(app.getAppPath(), "install")
-  const script = readFileSync(scriptPath, "utf8")
-  const tempScript = join(tmpdir(), "opencode-install.sh")
-
-  writeFileSync(tempScript, script, "utf8")
-  chmodSync(tempScript, 0o755)
-
-  const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
-  return await new Promise<string>((resolve, reject) => {
-    cmd.on("exit", (code: number | null) => {
-      try {
-        unlinkSync(tempScript)
-      } catch {}
-      if (code === 0) {
-        const installPath = getCliInstallPath()
-        if (installPath) return resolve(installPath)
-        return reject(new Error("Could not determine install path"))
-      }
-      reject(new Error("Install script failed"))
-    })
-  })
-}
-
-export function syncCli() {
-  if (!app.isPackaged) return
-  const installPath = getCliInstallPath()
-  if (!installPath) return
-
-  let version = ""
-  try {
-    version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
-  } catch {
-    return
-  }
-
-  const cli = parseVersion(version)
-  const appVersion = parseVersion(app.getVersion())
-  if (!cli || !appVersion) return
-  if (compareVersions(cli, appVersion) >= 0) return
-  void installCli().catch(() => undefined)
-}
-
-export function serve(hostname: string, port: number, password: string) {
-  const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
-  const env = {
-    OPENCODE_SERVER_USERNAME: "opencode",
-    OPENCODE_SERVER_PASSWORD: password,
-  }
-
-  return spawnCommand(args, env)
-}
-
-export function spawnCommand(args: string, extraEnv: Record<string, string>) {
-  console.log(`[cli] Spawning command with args: ${args}`)
-  const base = Object.fromEntries(
-    Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
-  )
-  const env = {
-    ...base,
-    OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
-    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
-    OPENCODE_CLIENT: "desktop",
-    XDG_STATE_HOME: app.getPath("userData"),
-    ...extraEnv,
-  }
-  const shell = process.platform === "win32" ? null : getUserShell()
-  const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
-
-  const { cmd, cmdArgs } = buildCommand(args, envs, shell)
-  console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
-  const child = spawn(cmd, cmdArgs, {
-    env: envs,
-    detached: process.platform !== "win32",
-    windowsHide: true,
-    stdio: ["ignore", "pipe", "pipe"],
-  })
-  console.log(`[cli] Spawned process with PID: ${child.pid}`)
-
-  const events = new EventEmitter()
-  const exit = new Promise<TerminatedPayload>((resolve) => {
-    child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
-      console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
-      resolve({ code: code ?? null, signal: null })
-    })
-    child.on("error", (error: Error) => {
-      console.error(`[cli] Process error: ${error.message}`)
-      events.emit("error", error.message)
-    })
-  })
-
-  const stdout = child.stdout
-  const stderr = child.stderr
-
-  if (stdout) {
-    readline.createInterface({ input: stdout }).on("line", (line: string) => {
-      if (handleSqliteProgress(events, line)) return
-      events.emit("stdout", `${line}\n`)
-    })
-  }
-
-  if (stderr) {
-    readline.createInterface({ input: stderr }).on("line", (line: string) => {
-      if (handleSqliteProgress(events, line)) return
-      events.emit("stderr", `${line}\n`)
-    })
-  }
-
-  exit.then((payload) => {
-    events.emit("terminated", payload)
-  })
-
-  const kill = () => {
-    if (!child.pid) return
-    treeKill(child.pid)
-  }
-
-  return { events, child: { pid: child.pid, kill }, exit }
-}
-
-function handleSqliteProgress(events: EventEmitter, line: string) {
-  const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
-  if (!stripped) return false
-  if (stripped === "done") {
-    events.emit("sqlite", { type: "Done" })
-    return true
-  }
-  const value = Number.parseInt(stripped, 10)
-  if (!Number.isNaN(value)) {
-    events.emit("sqlite", { type: "InProgress", value })
-    return true
-  }
-  return false
-}
-
-function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
-  if (process.platform === "win32" && isWslEnabled()) {
-    console.log(`[cli] Using WSL mode`)
-    const version = app.getVersion()
-    const script = [
-      "set -e",
-      'BIN="$HOME/.opencode/bin/opencode"',
-      'if [ ! -x "$BIN" ]; then',
-      `  curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
-      "fi",
-      `${envPrefix(env)} exec "$BIN" ${args}`,
-    ].join("\n")
-
-    return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
-  }
-
-  if (process.platform === "win32") {
-    const sidecar = getSidecarPath()
-    console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
-    return { cmd: sidecar, cmdArgs: args.split(" ") }
-  }
-
-  const sidecar = getSidecarPath()
-  const user = shell || getUserShell()
-  const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
-  console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
-  return { cmd: user, cmdArgs: ["-l", "-c", line] }
-}
-
-function envPrefix(env: Record<string, string>) {
-  const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
-  return entries.join(" ")
-}
-
-function shellEscape(input: string) {
-  if (!input) return "''"
-  return `'${input.replace(/'/g, `'"'"'`)}'`
-}
-
-function getCliInstallPath() {
-  const home = process.env.HOME
-  if (!home) return null
-  return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
-}
-
-function isWslEnabled() {
-  return store.get(WSL_ENABLED_KEY) === true
-}
-
-function parseVersion(value: string) {
-  const parts = value
-    .replace(/^v/, "")
-    .split(".")
-    .map((part) => Number.parseInt(part, 10))
-  if (parts.some((part) => Number.isNaN(part))) return null
-  return parts
-}
-
-function compareVersions(a: number[], b: number[]) {
-  const len = Math.max(a.length, b.length)
-  for (let i = 0; i < len; i += 1) {
-    const left = a[i] ?? 0
-    const right = b[i] ?? 0
-    if (left > right) return 1
-    if (left < right) return -1
-  }
-  return 0
-}

+ 22 - 0
packages/desktop-electron/src/main/env.d.ts

@@ -5,3 +5,25 @@ interface ImportMetaEnv {
 interface ImportMeta {
   readonly env: ImportMetaEnv
 }
+declare module "virtual:opencode-server" {
+  export namespace Server {
+    export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
+    export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
+  }
+  export namespace Config {
+    export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
+    export type Info = import("../../../opencode/dist/types/src/node").Config.Info
+  }
+  export namespace Log {
+    export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
+  }
+  export namespace Database {
+    export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
+    export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
+  }
+  export namespace JsonMigration {
+    export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
+    export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
+  }
+  export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
+}

+ 10 - 22
packages/desktop-electron/src/main/index.ts

@@ -11,6 +11,8 @@ import pkg from "electron-updater"
 import contextMenu from "electron-context-menu"
 contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
 
+process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
+
 const APP_NAMES: Record<string, string> = {
   dev: "OpenCode Dev",
   beta: "OpenCode Beta",
@@ -27,8 +29,6 @@ const { autoUpdater } = pkg
 
 import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
 import { checkAppExists, resolveAppPath, wslPath } from "./apps"
-import type { CommandChild } from "./cli"
-import { installCli, syncCli } from "./cli"
 import { CHANNEL, UPDATER_ENABLED } from "./constants"
 import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
 import { initLogging } from "./logging"
@@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
 import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
 import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
+import type { Server } from "virtual:opencode-server"
 
 const initEmitter = new EventEmitter()
 let initStep: InitStep = { phase: "server_waiting" }
 
 let mainWindow: BrowserWindow | null = null
-let sidecar: CommandChild | null = null
+let server: Server.Listener | null = null
 const loadingComplete = defer<void>()
 
 const pendingDeepLinks: string[] = []
@@ -96,11 +97,9 @@ function setupApp() {
   }
 
   void app.whenReady().then(async () => {
-    // migrate()
     app.setAsDefaultProtocolClient("opencode")
     setDockIcon()
     setupAutoUpdater()
-    syncCli()
     await initialize()
   })
 }
@@ -134,8 +133,8 @@ async function initialize() {
   const password = randomUUID()
 
   logger.log("spawning sidecar", { url })
-  const { child, health, events } = spawnLocalServer(hostname, port, password)
-  sidecar = child
+  const { listener, health } = await spawnLocalServer(hostname, port, password)
+  server = listener
   serverReady.resolve({
     url,
     username: "opencode",
@@ -145,7 +144,7 @@ async function initialize() {
   const loadingTask = (async () => {
     logger.log("sidecar connection started", { url })
 
-    events.on("sqlite", (progress: SqliteMigrationProgress) => {
+    initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
       setInitStep({ phase: "sqlite_waiting" })
       if (overlay) sendSqliteMigrationProgress(overlay, progress)
       if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
@@ -198,9 +197,6 @@ function wireMenu() {
   if (!mainWindow) return
   createMenu({
     trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
-    installCli: () => {
-      void installCli()
-    },
     checkForUpdates: () => {
       void checkForUpdates(true)
     },
@@ -215,7 +211,6 @@ function wireMenu() {
 
 registerIpcHandlers({
   killSidecar: () => killSidecar(),
-  installCli: async () => installCli(),
   awaitInitialization: async (sendStep) => {
     sendStep(initStep)
     const listener = (step: InitStep) => sendStep(step)
@@ -247,16 +242,9 @@ registerIpcHandlers({
 })
 
 function killSidecar() {
-  if (!sidecar) return
-  const pid = sidecar.pid
-  sidecar.kill()
-  sidecar = null
-  // tree-kill is async; also send process group signal as immediate fallback
-  if (pid && process.platform !== "win32") {
-    try {
-      process.kill(-pid, "SIGTERM")
-    } catch {}
-  }
+  if (!server) return
+  server.stop()
+  server = null
 }
 
 function ensureLoopbackNoProxy() {

+ 0 - 2
packages/desktop-electron/src/main/ipc.ts

@@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
 
 type Deps = {
   killSidecar: () => void
-  installCli: () => Promise<string>
   awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
   getDefaultServerUrl: () => Promise<string | null> | string | null
   setDefaultServerUrl: (url: string | null) => Promise<void> | void
@@ -34,7 +33,6 @@ type Deps = {
 
 export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle("kill-sidecar", () => deps.killSidecar())
-  ipcMain.handle("install-cli", () => deps.installCli())
   ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
     const send = (step: InitStep) => event.sender.send("init-step", step)
     return deps.awaitInitialization(send)

+ 0 - 5
packages/desktop-electron/src/main/menu.ts

@@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
 
 type Deps = {
   trigger: (id: string) => void
-  installCli: () => void
   checkForUpdates: () => void
   reload: () => void
   relaunch: () => void
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
           enabled: UPDATER_ENABLED,
           click: () => deps.checkForUpdates(),
         },
-        {
-          label: "Install CLI...",
-          click: () => deps.installCli(),
-        },
         {
           label: "Reload Webview",
           click: () => deps.reload(),

+ 30 - 16
packages/desktop-electron/src/main/server.ts

@@ -1,5 +1,6 @@
-import { serve, type CommandChild } from "./cli"
+import { app } from "electron"
 import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { getUserShell, loadShellEnv } from "./shell-env"
 import { store } from "./store"
 
 export type WslConfig = { enabled: boolean }
@@ -29,8 +30,16 @@ export function setWslConfig(config: WslConfig) {
   store.set(WSL_ENABLED_KEY, config.enabled)
 }
 
-export function spawnLocalServer(hostname: string, port: number, password: string) {
-  const { child, exit, events } = serve(hostname, port, password)
+export async function spawnLocalServer(hostname: string, port: number, password: string) {
+  prepareServerEnv(password)
+  const { Log, Server } = await import("virtual:opencode-server")
+  await Log.init({ level: "WARN" })
+  const listener = await Server.listen({
+    port,
+    hostname,
+    username: "opencode",
+    password,
+  })
 
   const wait = (async () => {
     const url = `http://${hostname}:${port}`
@@ -42,19 +51,26 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
       }
     }
 
-    const terminated = async () => {
-      const payload = await exit
-      throw new Error(
-        `Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
-          payload.signal ?? "unknown"
-        })`,
-      )
-    }
-
-    await Promise.race([ready(), terminated()])
+    await ready()
   })()
 
-  return { child, health: { wait }, events }
+  return { listener, health: { wait } }
+}
+
+function prepareServerEnv(password: string) {
+  const shell = process.platform === "win32" ? null : getUserShell()
+  const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
+  const env = {
+    ...process.env,
+    ...shellEnv,
+    OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
+    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
+    OPENCODE_CLIENT: "desktop",
+    OPENCODE_SERVER_USERNAME: "opencode",
+    OPENCODE_SERVER_PASSWORD: password,
+    XDG_STATE_HOME: app.getPath("userData"),
+  }
+  Object.assign(process.env, env)
 }
 
 export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
@@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
     return false
   }
 }
-
-export type { CommandChild }

+ 13 - 13
packages/desktop-electron/src/main/shell-env.ts

@@ -1,7 +1,7 @@
 import { spawnSync } from "node:child_process"
 import { basename } from "node:path"
 
-const SHELL_ENV_TIMEOUT = 5_000
+const TIMEOUT = 5_000
 
 type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
 
@@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) {
   return env
 }
 
-function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
+function probe(shell: string, mode: "-il" | "-l"): Probe {
   const out = spawnSync(shell, [mode, "-c", "env -0"], {
     stdio: ["ignore", "pipe", "ignore"],
-    timeout: SHELL_ENV_TIMEOUT,
+    timeout: TIMEOUT,
     windowsHide: true,
   })
 
   const err = out.error as NodeJS.ErrnoException | undefined
   if (err) {
     if (err.code === "ETIMEDOUT") return { type: "Timeout" }
-    console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
+    console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
     return { type: "Unavailable" }
   }
 
   if (out.status !== 0) {
-    console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
+    console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
     return { type: "Unavailable" }
   }
 
   const env = parseShellEnv(out.stdout)
   if (Object.keys(env).length === 0) {
-    console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
+    console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
     return { type: "Unavailable" }
   }
 
@@ -56,27 +56,27 @@ export function isNushell(shell: string) {
 
 export function loadShellEnv(shell: string) {
   if (isNushell(shell)) {
-    console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
+    console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
     return null
   }
 
-  const interactive = probeShellEnv(shell, "-il")
+  const interactive = probe(shell, "-il")
   if (interactive.type === "Loaded") {
-    console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
+    console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
     return interactive.value
   }
   if (interactive.type === "Timeout") {
-    console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
+    console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
     return null
   }
 
-  const login = probeShellEnv(shell, "-l")
+  const login = probe(shell, "-l")
   if (login.type === "Loaded") {
-    console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
+    console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
     return login.value
   }
 
-  console.warn(`[cli] Falling back to app environment: ${shell}`)
+  console.warn(`[server] Falling back to app environment: ${shell}`)
   return null
 }
 

+ 1 - 1
packages/desktop/package.json

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

+ 5 - 2
packages/desktop/scripts/finalize-latest-json.ts

@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
 if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
 
 const version = process.env.OPENCODE_VERSION
-if (!releaseId) throw new Error("OPENCODE_VERSION is required")
+if (!version) throw new Error("OPENCODE_VERSION is required")
 
 const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
 if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
@@ -54,7 +54,10 @@ const assets = release.assets ?? []
 const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
 
 const latestAsset = assetByName.get("latest.json")
-if (!latestAsset) throw new Error("latest.json asset not found")
+if (!latestAsset) {
+  console.log("latest.json not found, skipping tauri finalization")
+  process.exit(0)
+}
 
 const latestRes = await fetch(latestAsset.url, {
   headers: {

+ 1 - 1
packages/enterprise/package.json

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

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

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

+ 1 - 1
packages/function/package.json

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

+ 12 - 15
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.4.0",
+  "version": "1.4.3",
   "name": "opencode",
   "type": "module",
   "license": "MIT",
@@ -14,18 +14,11 @@
     "fix-node-pty": "bun run script/fix-node-pty.ts",
     "upgrade-opentui": "bun run script/upgrade-opentui.ts",
     "dev": "bun run --conditions=browser ./src/index.ts",
-    "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
-    "clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
-    "lint": "echo 'Running lint checks...' && bun test --coverage",
-    "format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
-    "docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
-    "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
     "db": "bun drizzle-kit"
   },
   "bin": {
     "opencode": "./bin/opencode"
   },
-  "randomField": "this-is-a-random-value-12345",
   "exports": {
     "./*": "./src/*.ts"
   },
@@ -39,11 +32,16 @@
       "bun": "./src/pty/pty.bun.ts",
       "node": "./src/pty/pty.node.ts",
       "default": "./src/pty/pty.bun.ts"
+    },
+    "#hono": {
+      "bun": "./src/server/adapter.bun.ts",
+      "node": "./src/server/adapter.node.ts",
+      "default": "./src/server/adapter.bun.ts"
     }
   },
   "devDependencies": {
     "@babel/core": "7.28.4",
-    "@effect/language-service": "0.79.0",
+    "@effect/language-service": "0.84.2",
     "@octokit/webhooks-types": "7.6.1",
     "@opencode-ai/script": "workspace:*",
     "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -79,7 +77,7 @@
     "@actions/core": "1.11.1",
     "@actions/github": "6.0.1",
     "@agentclientprotocol/sdk": "0.16.1",
-    "@ai-sdk/amazon-bedrock": "4.0.83",
+    "@ai-sdk/amazon-bedrock": "4.0.93",
     "@ai-sdk/anthropic": "3.0.67",
     "@ai-sdk/azure": "3.0.49",
     "@ai-sdk/cerebras": "2.0.41",
@@ -91,7 +89,7 @@
     "@ai-sdk/groq": "3.0.31",
     "@ai-sdk/mistral": "3.0.27",
     "@ai-sdk/openai": "3.0.48",
-    "@ai-sdk/openai-compatible": "2.0.37",
+    "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/perplexity": "3.0.26",
     "@ai-sdk/provider": "3.0.8",
     "@ai-sdk/provider-utils": "4.0.23",
@@ -101,13 +99,12 @@
     "@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/node-server": "1.19.11",
     "@hono/node-ws": "1.3.0",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
-    "@lydell/node-pty": "1.2.0-beta.10",
+    "@lydell/node-pty": "catalog:",
     "@modelcontextprotocol/sdk": "1.27.1",
     "@npmcli/arborist": "9.4.0",
     "@octokit/graphql": "9.0.2",
@@ -117,7 +114,7 @@
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
-    "@openrouter/ai-sdk-provider": "2.4.2",
+    "@openrouter/ai-sdk-provider": "2.5.1",
     "@opentui/core": "0.1.97",
     "@opentui/solid": "0.1.97",
     "@parcel/watcher": "2.5.1",
@@ -138,7 +135,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "6.0.0",
+    "gitlab-ai-provider": "6.4.2",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",

+ 7 - 16
packages/opencode/script/build-node.ts

@@ -1,6 +1,5 @@
 #!/usr/bin/env bun
 
-import { $ } from "bun"
 import { Script } from "@opencode-ai/script"
 import fs from "fs"
 import path from "path"
@@ -9,18 +8,11 @@ import { fileURLToPath } from "url"
 const __filename = fileURLToPath(import.meta.url)
 const __dirname = path.dirname(__filename)
 const dir = path.resolve(__dirname, "..")
-const root = path.resolve(dir, "../..")
-
-function linker(): "hoisted" | "isolated" {
-  // jsonc-parser is only declared in packages/opencode, so its install location
-  // tells us whether Bun used a hoisted or isolated workspace layout.
-  if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated"
-  if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted"
-  throw new Error("Could not detect Bun linker from jsonc-parser")
-}
 
 process.chdir(dir)
 
+await import("./generate.ts")
+
 // Load migrations from migration directories
 const migrationDirs = (
   await fs.promises.readdir(path.join(dir, "migration"), {
@@ -51,21 +43,20 @@ const migrations = await Promise.all(
 )
 console.log(`Loaded ${migrations.length} migrations`)
 
-const link = linker()
-
-await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/[email protected]`
-
 await Bun.build({
   target: "node",
   entrypoints: ["./src/node.ts"],
-  outdir: "./dist",
+  outdir: "./dist/node",
   format: "esm",
   sourcemap: "linked",
-  external: ["jsonc-parser"],
+  external: ["jsonc-parser", "@lydell/node-pty"],
   define: {
     OPENCODE_MIGRATIONS: JSON.stringify(migrations),
     OPENCODE_CHANNEL: `'${Script.channel}'`,
   },
+  files: {
+    "opencode-web-ui.gen.ts": "",
+  },
 })
 
 console.log("Build complete")

+ 2 - 15
packages/opencode/script/build.ts

@@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..")
 
 process.chdir(dir)
 
+await import("./generate.ts")
+
 import { Script } from "@opencode-ai/script"
 import pkg from "../package.json"
 
-const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
-// Fetch and generate models.dev snapshot
-const modelsData = process.env.MODELS_DEV_API_JSON
-  ? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
-  : await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
-await Bun.write(
-  path.join(dir, "src/provider/models-snapshot.js"),
-  `// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
-)
-await Bun.write(
-  path.join(dir, "src/provider/models-snapshot.d.ts"),
-  `// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
-)
-console.log("Generated models-snapshot.js")
-
 // Load migrations from migration directories
 const migrationDirs = (
   await fs.promises.readdir(path.join(dir, "migration"), {

+ 23 - 0
packages/opencode/script/generate.ts

@@ -0,0 +1,23 @@
+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)
+
+const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
+// Fetch and generate models.dev snapshot
+const modelsData = process.env.MODELS_DEV_API_JSON
+  ? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
+  : await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
+await Bun.write(
+  path.join(dir, "src/provider/models-snapshot.js"),
+  `// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
+)
+await Bun.write(
+  path.join(dir, "src/provider/models-snapshot.d.ts"),
+  `// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
+)
+console.log("Generated models-snapshot.js")

+ 87 - 10
packages/opencode/specs/effect-migration.md

@@ -23,7 +23,7 @@ export namespace Foo {
     readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
 
   export const layer = Layer.effect(
     Service,
@@ -217,36 +217,37 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
 - [x] `SessionSummary` — `session/summary.ts`
 - [x] `SessionRevert` — `session/revert.ts`
 - [x] `Instruction` — `session/instruction.ts`
+- [x] `SystemPrompt` — `session/system.ts`
 - [x] `Provider` — `provider/provider.ts`
 - [x] `Storage` — `storage/storage.ts`
+- [x] `ShareNext` — `share/share-next.ts`
 
 Still open:
 
-- [ ] `SessionTodo` — `session/todo.ts`
-- [ ] `ShareNext` — `share/share-next.ts`
+- [x] `SessionTodo` — `session/todo.ts`
 - [ ] `SyncEvent` — `sync/index.ts`
 - [ ] `Workspace` — `control-plane/workspace.ts`
 
 ## Tool interface → Effect
 
-Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
+`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
 
-1. Migrate each tool to return Effects
-2. Update `Tool.define()` factory to work with Effects
-3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
+1. Migrate each tool body to return Effects
+2. Keep `Tool.define()` inputs Effect-native
+3. Update remaining callers to `yield*` tool initialization instead of `await`ing
 
 ### Tool migration details
 
-Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
+With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
 
 - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
-- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
+- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
 - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
 
 Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
 
 - Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
-- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
+- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
 - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
 
 This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
@@ -308,3 +309,79 @@ Current raw fs users that will convert during tool migration:
 - [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
 - [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
 - [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
+
+## Destroying the facades
+
+Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
+
+### Process
+
+For each service, the migration is roughly:
+
+1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
+2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
+   - Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
+   - Yield it at the top of the layer: `const ns = yield* Namespace.Service`
+   - Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
+   - Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
+3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
+4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
+5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
+
+### Pitfalls
+
+- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
+- **`Effect.tryPromise` → `yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
+- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
+- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
+
+### Migration log
+
+- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
+- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
+- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
+- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
+- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
+- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
+- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
+- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
+- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
+- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
+- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
+
+## Route handler effectification
+
+Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
+
+```ts
+// Before — one facade call per service
+;async (c) => {
+  await SessionRunState.assertNotBusy(id)
+  await Session.removeMessage({ sessionID: id, messageID })
+  return c.json(true)
+}
+
+// After — one Effect.gen, yield services from context
+;async (c) => {
+  await AppRuntime.runPromise(
+    Effect.gen(function* () {
+      const state = yield* SessionRunState.Service
+      const session = yield* Session.Service
+      yield* state.assertNotBusy(id)
+      yield* session.removeMessage({ sessionID: id, messageID })
+    }),
+  )
+  return c.json(true)
+}
+```
+
+When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
+
+Route files to convert (each handler that calls facades should be wrapped):
+
+- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
+- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
+- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
+- [ ] `server/routes/question.ts` — uses Question
+- [ ] `server/routes/pty.ts` — uses Pty
+- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config

+ 1 - 4
packages/opencode/specs/tui-plugins.md

@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
 - `api.kv.get`, `set`, `ready`
 - `api.state`
 - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
-- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
+- `api.client`
 - `api.event.on(type, handler)`
 - `api.renderer`
 - `api.slots.register(plugin)`
@@ -270,7 +270,6 @@ Command behavior:
   - `provider`
   - `path.{state,config,worktree,directory}`
   - `vcs?.branch`
-  - `workspace.list()` / `workspace.get(workspaceID)`
   - `session.count()`
   - `session.diff(sessionID)`
   - `session.todo(sessionID)`
@@ -282,8 +281,6 @@ Command behavior:
   - `lsp()`
   - `mcp()`
 - `api.client` always reflects the current runtime client.
-- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
-- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
 - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
 - `api.renderer` exposes the raw `CliRenderer`.
 

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

@@ -1,4 +1,4 @@
-import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
+import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
 import {
   FetchHttpClient,
   HttpClient,
@@ -7,7 +7,6 @@ import {
   HttpClientResponse,
 } from "effect/unstable/http"
 
-import { makeRuntime } from "@/effect/run-service"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { AccountRepo, type AccountRow } from "./repo"
 import { normalizeServerUrl } from "./url"
@@ -181,7 +180,7 @@ export namespace Account {
     readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
 
   export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
     Service,
@@ -454,35 +453,4 @@ export namespace Account {
   )
 
   export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
-
-  export const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function active(): Promise<Info | undefined> {
-    return Option.getOrUndefined(await runPromise((service) => service.active()))
-  }
-
-  export async function list(): Promise<Info[]> {
-    return runPromise((service) => service.list())
-  }
-
-  export async function activeOrg(): Promise<ActiveOrg | undefined> {
-    return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
-  }
-
-  export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
-    return runPromise((service) => service.orgsByAccount())
-  }
-
-  export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
-    return runPromise((service) => service.orgs(accountID))
-  }
-
-  export async function switchOrg(accountID: AccountID, orgID: OrgID) {
-    return runPromise((service) => service.use(accountID, Option.some(orgID)))
-  }
-
-  export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
-    const t = await runPromise((service) => service.token(accountID))
-    return Option.getOrUndefined(t)
-  }
 }

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

@@ -1,5 +1,5 @@
 import { eq } from "drizzle-orm"
-import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
+import { Effect, Layer, Option, Schema, Context } from "effect"
 
 import { Database } from "@/storage/db"
 import { AccountStateTable, AccountTable } from "./account.sql"
@@ -38,7 +38,7 @@ export namespace AccountRepo {
   }
 }
 
-export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
+export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
   static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
     AccountRepo,
     Effect.gen(function* () {

+ 6 - 26
packages/opencode/src/account/schema.ts

@@ -1,42 +1,22 @@
 import { Schema } from "effect"
 import type * as HttpClientError from "effect/unstable/http/HttpClientError"
 
-import { withStatics } from "@/util/schema"
-
-export const AccountID = Schema.String.pipe(
-  Schema.brand("AccountID"),
-  withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
-)
+export const AccountID = Schema.String.pipe(Schema.brand("AccountID"))
 export type AccountID = Schema.Schema.Type<typeof AccountID>
 
-export const OrgID = Schema.String.pipe(
-  Schema.brand("OrgID"),
-  withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
-)
+export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
 export type OrgID = Schema.Schema.Type<typeof OrgID>
 
-export const AccessToken = Schema.String.pipe(
-  Schema.brand("AccessToken"),
-  withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
-)
+export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
 export type AccessToken = Schema.Schema.Type<typeof AccessToken>
 
-export const RefreshToken = Schema.String.pipe(
-  Schema.brand("RefreshToken"),
-  withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
-)
+export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
 export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
 
-export const DeviceCode = Schema.String.pipe(
-  Schema.brand("DeviceCode"),
-  withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
-)
+export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
 export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
 
-export const UserCode = Schema.String.pipe(
-  Schema.brand("UserCode"),
-  withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
-)
+export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
 export type UserCode = Schema.Schema.Type<typeof UserCode>
 
 export class Info extends Schema.Class<Info>("Account")({

+ 16 - 11
packages/opencode/src/agent/agent.ts

@@ -19,7 +19,7 @@ import { Global } from "@/global"
 import path from "path"
 import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
-import { Effect, ServiceMap, Layer } from "effect"
+import { Effect, Context, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 
@@ -67,7 +67,7 @@ export namespace Agent {
 
   type State = Omit<Interface, "generate">
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
 
   export const layer = Layer.effect(
     Service,
@@ -341,6 +341,10 @@ export namespace Agent {
           )
           const existing = yield* InstanceState.useEffect(state, (s) => s.list())
 
+          // TODO: clean this up so provider specific logic doesnt bleed over
+          const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
+          const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
+
           const params = {
             experimental_telemetry: {
               isEnabled: cfg.experimental?.openTelemetry,
@@ -350,12 +354,14 @@ export namespace Agent {
             },
             temperature: 0.3,
             messages: [
-              ...system.map(
-                (item): ModelMessage => ({
-                  role: "system",
-                  content: item,
-                }),
-              ),
+              ...(isOpenaiOauth
+                ? []
+                : system.map(
+                    (item): ModelMessage => ({
+                      role: "system",
+                      content: item,
+                    }),
+                  )),
               {
                 role: "user",
                 content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n  Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -369,13 +375,12 @@ export namespace Agent {
             }),
           } satisfies Parameters<typeof generateObject>[0]
 
-          // TODO: clean this up so provider specific logic doesnt bleed over
-          const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
-          if (model.providerID === "openai" && authInfo?.type === "oauth") {
+          if (isOpenaiOauth) {
             return yield* Effect.promise(async () => {
               const result = streamObject({
                 ...params,
                 providerOptions: ProviderTransform.providerOptions(resolved, {
+                  instructions: system.join("\n"),
                   store: false,
                 }),
                 onError: () => {},

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

@@ -1,5 +1,5 @@
 import path from "path"
-import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
+import { Effect, Layer, Record, Result, Schema, Context } from "effect"
 import { makeRuntime } from "@/effect/run-service"
 import { zod } from "@/util/effect-zod"
 import { Global } from "../global"
@@ -49,7 +49,7 @@ export namespace Auth {
     readonly remove: (key: string) => Effect.Effect<void, AuthError>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
 
   export const layer = Layer.effect(
     Service,

+ 2 - 0
packages/opencode/src/bus/global.ts

@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
   event: [
     {
       directory?: string
+      project?: string
+      workspace?: string
       payload: any
     },
   ]

+ 12 - 4
packages/opencode/src/bus/index.ts

@@ -1,9 +1,10 @@
 import z from "zod"
-import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
+import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
+import { EffectLogger } from "@/effect/logger"
 import { Log } from "../util/log"
-import { Instance } from "../project/instance"
 import { BusEvent } from "./bus-event"
 import { GlobalBus } from "./global"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 
@@ -41,7 +42,7 @@ export namespace Bus {
     readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
 
   export const layer = Layer.effect(
     Service,
@@ -91,8 +92,13 @@ export namespace Bus {
           yield* PubSub.publish(s.wildcard, payload)
 
           const dir = yield* InstanceState.directory
+          const context = yield* InstanceState.context
+          const workspace = yield* InstanceState.workspaceID
+
           GlobalBus.emit("event", {
             directory: dir,
+            project: context.project.id,
+            workspace,
             payload,
           })
         })
@@ -141,7 +147,7 @@ export namespace Bus {
 
           return () => {
             log.info("unsubscribing", { type })
-            Effect.runFork(Scope.close(scope, Exit.void))
+            Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer)))
           }
         })
       }
@@ -164,6 +170,8 @@ export namespace Bus {
     }),
   )
 
+  export const defaultLayer = layer
+
   const { runPromise, runSync } = makeRuntime(Service, layer)
 
   // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,

+ 6 - 5
packages/opencode/src/cli/cmd/account.ts

@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
 import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
 import { type AccountError } from "@/account/schema"
+import { AppRuntime } from "@/effect/app-runtime"
 import * as Prompt from "../effect/prompt"
 import open from "open"
 
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
     }),
   async handler(args) {
     UI.empty()
-    await Account.runPromise((_svc) => loginEffect(args.url))
+    await AppRuntime.runPromise(loginEffect(args.url))
   },
 })
 
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
     }),
   async handler(args) {
     UI.empty()
-    await Account.runPromise((_svc) => logoutEffect(args.email))
+    await AppRuntime.runPromise(logoutEffect(args.email))
   },
 })
 
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
   describe: false,
   async handler() {
     UI.empty()
-    await Account.runPromise((_svc) => switchEffect())
+    await AppRuntime.runPromise(switchEffect())
   },
 })
 
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
   describe: false,
   async handler() {
     UI.empty()
-    await Account.runPromise((_svc) => orgsEffect())
+    await AppRuntime.runPromise(orgsEffect())
   },
 })
 
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
   describe: false,
   async handler() {
     UI.empty()
-    await Account.runPromise((_svc) => openEffect())
+    await AppRuntime.runPromise(openEffect())
   },
 })
 

+ 2 - 1
packages/opencode/src/cli/cmd/db.ts

@@ -1,6 +1,7 @@
 import type { Argv } from "yargs"
 import { spawn } from "child_process"
 import { Database } from "../../storage/db"
+import { drizzle } from "drizzle-orm/bun-sqlite"
 import { Database as BunDatabase } from "bun:sqlite"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
@@ -74,7 +75,7 @@ const MigrateCommand = cmd({
     let last = -1
     if (tty) process.stderr.write("\x1b[?25l")
     try {
-      const stats = await JsonMigration.run(sqlite, {
+      const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
         progress: (event) => {
           const percent = Math.floor((event.current / event.total) * 100)
           if (percent === last) return

+ 10 - 7
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -1,5 +1,6 @@
 import { EOL } from "os"
 import { basename } from "path"
+import { Effect } from "effect"
 import { Agent } from "../../../agent/agent"
 import { Provider } from "../../../provider/provider"
 import { Session } from "../../../session"
@@ -157,14 +158,16 @@ async function createToolContext(agent: Agent.Info) {
     agent: agent.name,
     abort: new AbortController().signal,
     messages: [],
-    metadata: () => {},
-    async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
-      for (const pattern of req.patterns) {
-        const rule = Permission.evaluate(req.permission, pattern, ruleset)
-        if (rule.action === "deny") {
-          throw new Permission.DeniedError({ ruleset })
+    metadata: () => Effect.void,
+    ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
+      return Effect.sync(() => {
+        for (const pattern of req.patterns) {
+          const rule = Permission.evaluate(req.permission, pattern, ruleset)
+          if (rule.action === "deny") {
+            throw new Permission.DeniedError({ ruleset })
+          }
         }
-      }
+      })
     },
   }
 }

+ 10 - 5
packages/opencode/src/cli/cmd/github.ts

@@ -21,6 +21,7 @@ import { cmd } from "./cmd"
 import { ModelsDev } from "../../provider/models"
 import { Instance } from "@/project/instance"
 import { bootstrap } from "../bootstrap"
+import { SessionShare } from "@/share/session"
 import { Session } from "../../session"
 import type { SessionID } from "../../session/schema"
 import { MessageID, PartID } from "../../session/schema"
@@ -28,6 +29,7 @@ import { Provider } from "../../provider/provider"
 import { Bus } from "../../bus"
 import { MessageV2 } from "../../session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Git } from "@/git"
 import { setTimeout as sleep } from "node:timers/promises"
 import { Process } from "@/util/process"
@@ -257,7 +259,9 @@ export const GithubInstallCommand = cmd({
             }
 
             // Get repo info
-            const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
+            const info = await AppRuntime.runPromise(
+              Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
+            ).then((x) => x.text().trim())
             const parsed = parseGitHubRemote(info)
             if (!parsed) {
               prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -496,20 +500,21 @@ export const GithubRunCommand = cmd({
           : "issue"
         : undefined
       const gitText = async (args: string[]) => {
-        const result = await Git.run(args, { cwd: Instance.worktree })
+        const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result.text().trim()
       }
       const gitRun = async (args: string[]) => {
-        const result = await Git.run(args, { cwd: Instance.worktree })
+        const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
         if (result.exitCode !== 0) {
           throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
         }
         return result
       }
-      const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
+      const gitStatus = (args: string[]) =>
+        AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
       const commitChanges = async (summary: string, actor?: string) => {
         const args = ["commit", "-m", summary]
         if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -559,7 +564,7 @@ export const GithubRunCommand = cmd({
         shareId = await (async () => {
           if (share === false) return
           if (!share && repoData.data.private) return
-          await Session.share(session.id)
+          await SessionShare.share(session.id)
           return session.id.slice(-8)
         })()
         console.log("opencode session", session.id)

+ 3 - 2
packages/opencode/src/cli/cmd/import.ts

@@ -10,6 +10,7 @@ import { Instance } from "../../project/instance"
 import { ShareNext } from "../../share/share-next"
 import { EOL } from "os"
 import { Filesystem } from "../../util/filesystem"
+import { AppRuntime } from "@/effect/app-runtime"
 
 /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
 export type ShareData =
@@ -100,7 +101,7 @@ export const ImportCommand = cmd({
       if (isUrl) {
         const slug = parseShareUrl(args.file)
         if (!slug) {
-          const baseUrl = await ShareNext.url()
+          const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
           process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
           process.stdout.write(EOL)
           return
@@ -108,7 +109,7 @@ export const ImportCommand = cmd({
 
         const parsed = new URL(args.file)
         const baseUrl = parsed.origin
-        const req = await ShareNext.request()
+        const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
         const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
 
         const dataPath = req.api.data(slug)

+ 1 - 0
packages/opencode/src/cli/cmd/mcp.ts

@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
                 clientId: oauthConfig?.clientId,
                 clientSecret: oauthConfig?.clientSecret,
                 scope: oauthConfig?.scope,
+                redirectUri: oauthConfig?.redirectUri,
               },
               {
                 onRedirect: async () => {},

+ 18 - 7
packages/opencode/src/cli/cmd/pr.ts

@@ -1,5 +1,6 @@
 import { UI } from "../ui"
 import { cmd } from "./cmd"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Git } from "@/git"
 import { Instance } from "@/project/instance"
 import { Process } from "@/util/process"
@@ -67,19 +68,29 @@ export const PrCommand = cmd({
               const remoteName = forkOwner
 
               // Check if remote already exists
-              const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
+              const remotes = await AppRuntime.runPromise(
+                Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
+              ).then((x) => x.text().trim())
               if (!remotes.split("\n").includes(remoteName)) {
-                await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
-                  cwd: Instance.worktree,
-                })
+                await AppRuntime.runPromise(
+                  Git.Service.use((git) =>
+                    git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
+                      cwd: Instance.worktree,
+                    }),
+                  ),
+                )
                 UI.println(`Added fork remote: ${remoteName}`)
               }
 
               // Set upstream to the fork so pushes go there
               const headRefName = prInfo.headRefName
-              await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
-                cwd: Instance.worktree,
-              })
+              await AppRuntime.runPromise(
+                Git.Service.use((git) =>
+                  git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
+                    cwd: Instance.worktree,
+                  }),
+                ),
+              )
             }
 
             // Check for opencode session link in PR body

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

@@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag"
 import { bootstrap } from "../bootstrap"
 import { EOL } from "os"
 import { Filesystem } from "../../util/filesystem"
-import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
 import { Server } from "../../server/server"
 import { Provider } from "../../provider/provider"
 import { Agent } from "../../agent/agent"
@@ -680,7 +680,7 @@ export const RunCommand = cmd({
     await bootstrap(process.cwd(), async () => {
       const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
         const request = new Request(input, init)
-        return Server.Default().fetch(request)
+        return Server.Default().app.fetch(request)
       }) as typeof globalThis.fetch
       const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
       await execute(sdk)

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

@@ -14,7 +14,6 @@ import {
   batch,
   Show,
   on,
-  onCleanup,
 } from "solid-js"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
 import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
 import { ErrorComponent } from "@tui/component/error-component"
 import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
+import { ProjectProvider, useProject } from "@tui/context/project"
+import { useEvent } from "@tui/context/event"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { StartupLoading } from "@tui/component/startup-loading"
 import { SyncProvider, useSync } from "@tui/context/sync"
@@ -36,7 +37,6 @@ import { DialogPair } from "@tui/component/dialog-pair"
 import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
 import { DialogAgent } from "@tui/component/dialog-agent"
 import { DialogSessionList } from "@tui/component/dialog-session-list"
-import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
 import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
 import { KeybindProvider, useKeybind } from "@tui/context/keybind"
 import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -55,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
 import { Provider } from "@/provider/provider"
 import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
-import { writeHeapSnapshot } from "v8"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
 import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
 import { TuiConfig } from "@/config/tui"
@@ -217,27 +216,29 @@ export function tui(input: {
                         headers={input.headers}
                         events={input.events}
                       >
-                        <SyncProvider>
-                          <ThemeProvider mode={mode}>
-                            <LocalProvider>
-                              <KeybindProvider>
-                                <PromptStashProvider>
-                                  <DialogProvider>
-                                    <CommandProvider>
-                                      <FrecencyProvider>
-                                        <PromptHistoryProvider>
-                                          <PromptRefProvider>
-                                            <App onSnapshot={input.onSnapshot} />
-                                          </PromptRefProvider>
-                                        </PromptHistoryProvider>
-                                      </FrecencyProvider>
-                                    </CommandProvider>
-                                  </DialogProvider>
-                                </PromptStashProvider>
-                              </KeybindProvider>
-                            </LocalProvider>
-                          </ThemeProvider>
-                        </SyncProvider>
+                        <ProjectProvider>
+                          <SyncProvider>
+                            <ThemeProvider mode={mode}>
+                              <LocalProvider>
+                                <KeybindProvider>
+                                  <PromptStashProvider>
+                                    <DialogProvider>
+                                      <CommandProvider>
+                                        <FrecencyProvider>
+                                          <PromptHistoryProvider>
+                                            <PromptRefProvider>
+                                              <App onSnapshot={input.onSnapshot} />
+                                            </PromptRefProvider>
+                                          </PromptHistoryProvider>
+                                        </FrecencyProvider>
+                                      </CommandProvider>
+                                    </DialogProvider>
+                                  </PromptStashProvider>
+                                </KeybindProvider>
+                              </LocalProvider>
+                            </ThemeProvider>
+                          </SyncProvider>
+                        </ProjectProvider>
                       </SDKProvider>
                     </TuiConfigProvider>
                   </RouteProvider>
@@ -261,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
   const kv = useKV()
   const command = useCommandDialog()
   const keybind = useKeybind()
+  const event = useEvent()
   const sdk = useSDK()
   const toast = useToast()
   const themeState = useTheme()
@@ -284,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     route,
     routes,
     bump: () => setRouteRev((x) => x + 1),
+    event,
     sdk,
     sync,
     theme: themeState,
@@ -462,22 +465,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         dialog.replace(() => <DialogSessionList />)
       },
     },
-    ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
-      ? [
-          {
-            title: "Manage workspaces",
-            value: "workspace.list",
-            category: "Workspace",
-            suggested: true,
-            slash: {
-              name: "workspaces",
-            },
-            onSelect: () => {
-              dialog.replace(() => <DialogWorkspaceList />)
-            },
-          },
-        ]
-      : []),
     {
       title: "New session",
       suggested: route.data.type === "session",
@@ -492,12 +479,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
         const current = promptRef.current
         // Don't require focus - if there's any text, preserve it
         const currentPrompt = current?.current?.input ? current.current : undefined
-        const workspaceID =
-          route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
         route.navigate({
           type: "home",
           initialPrompt: currentPrompt,
-          workspaceID,
         })
         dialog.clear()
       },
@@ -818,11 +802,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     },
   ])
 
-  sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
+  event.on(TuiEvent.CommandExecute.type, (evt) => {
     command.trigger(evt.properties.command)
   })
 
-  sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
+  event.on(TuiEvent.ToastShow.type, (evt) => {
     toast.show({
       title: evt.properties.title,
       message: evt.properties.message,
@@ -831,14 +815,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     })
   })
 
-  sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
+  event.on(TuiEvent.SessionSelect.type, (evt) => {
     route.navigate({
       type: "session",
       sessionID: evt.properties.sessionID,
     })
   })
 
-  sdk.event.on("session.deleted", (evt) => {
+  event.on("session.deleted", (evt) => {
     if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
       route.navigate({ type: "home" })
       toast.show({
@@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     }
   })
 
-  sdk.event.on("session.error", (evt) => {
+  event.on("session.error", (evt) => {
     const error = evt.properties.error
     if (error && typeof error === "object" && error.name === "MessageAbortedError") return
     const message = errorMessage(error)
@@ -860,7 +844,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
     })
   })
 
-  sdk.event.on("installation.update-available", async (evt) => {
+  event.on("installation.update-available", async (evt) => {
     const version = evt.properties.version
 
     const skipped = kv.get("skipped_version")

+ 99 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-go-upsell.tsx

@@ -0,0 +1,99 @@
+import { RGBA, TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import open from "open"
+import { createSignal } from "solid-js"
+import { selectedForeground, useTheme } from "@tui/context/theme"
+import { useDialog, type DialogContext } from "@tui/ui/dialog"
+import { Link } from "@tui/ui/link"
+
+const GO_URL = "https://opencode.ai/go"
+
+export type DialogGoUpsellProps = {
+  onClose?: (dontShowAgain?: boolean) => void
+}
+
+function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
+  open(GO_URL).catch(() => {})
+  props.onClose?.()
+  dialog.clear()
+}
+
+function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
+  props.onClose?.(true)
+  dialog.clear()
+}
+
+export function DialogGoUpsell(props: DialogGoUpsellProps) {
+  const dialog = useDialog()
+  const { theme } = useTheme()
+  const fg = selectedForeground(theme)
+  const [selected, setSelected] = createSignal(0)
+
+  useKeyboard((evt) => {
+    if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
+      setSelected((s) => (s === 0 ? 1 : 0))
+      return
+    }
+    if (evt.name !== "return") return
+    if (selected() === 0) subscribe(props, dialog)
+    else dismiss(props, dialog)
+  })
+
+  return (
+    <box paddingLeft={2} paddingRight={2} gap={1}>
+      <box flexDirection="row" justifyContent="space-between">
+        <text attributes={TextAttributes.BOLD} fg={theme.text}>
+          Free limit reached
+        </text>
+        <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+          esc
+        </text>
+      </box>
+      <box gap={1} paddingBottom={1}>
+        <text fg={theme.textMuted}>
+          Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
+          $5/month.
+        </text>
+        <box flexDirection="row" gap={1}>
+          <Link href={GO_URL} fg={theme.primary} />
+        </box>
+      </box>
+      <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
+        <box
+          paddingLeft={3}
+          paddingRight={3}
+          backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+          onMouseOver={() => setSelected(0)}
+          onMouseUp={() => subscribe(props, dialog)}
+        >
+          <text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
+            subscribe
+          </text>
+        </box>
+        <box
+          paddingLeft={3}
+          paddingRight={3}
+          backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
+          onMouseOver={() => setSelected(1)}
+          onMouseUp={() => dismiss(props, dialog)}
+        >
+          <text
+            fg={selected() === 1 ? fg : theme.textMuted}
+            attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
+          >
+            don't show again
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}
+
+DialogGoUpsell.show = (dialog: DialogContext) => {
+  return new Promise<boolean>((resolve) => {
+    dialog.replace(
+      () => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
+      () => resolve(false),
+    )
+  })
+}

+ 74 - 6
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
 import { DialogSelect } from "@tui/ui/dialog-select"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
-import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
+import { createMemo, createResource, createSignal, onMount } from "solid-js"
 import { Locale } from "@/util/locale"
+import { useProject } from "@tui/context/project"
 import { useKeybind } from "../context/keybind"
 import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
+import { Flag } from "@/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
-import { useKV } from "../context/kv"
+import { Keybind } from "@/util/keybind"
 import { createDebouncedSignal } from "../util/signal"
+import { useToast } from "../ui/toast"
+import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
 import { Spinner } from "./spinner"
 
+type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+
 export function DialogSessionList() {
   const dialog = useDialog()
   const route = useRoute()
   const sync = useSync()
+  const project = useProject()
   const keybind = useKeybind()
   const { theme } = useTheme()
   const sdk = useSDK()
-  const kv = useKV()
-
+  const toast = useToast()
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
 
@@ -31,15 +37,68 @@ export function DialogSessionList() {
   })
 
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
-
   const sessions = createMemo(() => searchResults() ?? sync.data.session)
 
+  function createWorkspace() {
+    dialog.replace(() => (
+      <DialogWorkspaceCreate
+        onSelect={(workspaceID) =>
+          openWorkspaceSession({
+            dialog,
+            route,
+            sdk,
+            sync,
+            toast,
+            workspaceID,
+          })
+        }
+      />
+    ))
+  }
+
   const options = createMemo(() => {
     const today = new Date().toDateString()
     return sessions()
       .filter((x) => x.parentID === undefined)
       .toSorted((a, b) => b.time.updated - a.time.updated)
       .map((x) => {
+        const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
+
+        let workspaceStatus: WorkspaceStatus | null = null
+        if (x.workspaceID) {
+          workspaceStatus = project.workspace.status(x.workspaceID) || "error"
+        }
+
+        let footer = ""
+        if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+          if (x.workspaceID) {
+            let desc = "unknown"
+            if (workspace) {
+              desc = `${workspace.type}: ${workspace.name}`
+            }
+
+            footer = (
+              <>
+                {desc}{" "}
+                <span
+                  style={{
+                    fg:
+                      workspaceStatus === "error"
+                        ? theme.error
+                        : workspaceStatus === "disconnected"
+                          ? theme.textMuted
+                          : theme.success,
+                  }}
+                >
+                  ■
+                </span>
+              </>
+            )
+          }
+        } else {
+          footer = Locale.time(x.time.updated)
+        }
+
         const date = new Date(x.time.updated)
         let category = date.toDateString()
         if (category === today) {
@@ -53,7 +112,7 @@ export function DialogSessionList() {
           bg: isDeleting ? theme.error : undefined,
           value: x.id,
           category,
-          footer: Locale.time(x.time.updated),
+          footer,
           gutter: isWorking ? <Spinner /> : undefined,
         }
       })
@@ -102,6 +161,15 @@ export function DialogSessionList() {
             dialog.replace(() => <DialogSessionRename session={option.value} />)
           },
         },
+        {
+          keybind: Keybind.parse("ctrl+w")[0],
+          title: "new workspace",
+          side: "right",
+          disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
+          onTrigger: () => {
+            createWorkspace()
+          },
+        },
       ]}
     />
   )

+ 121 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -0,0 +1,121 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import { useDialog } from "@tui/ui/dialog"
+import { DialogSelect } from "@tui/ui/dialog-select"
+import { useRoute } from "@tui/context/route"
+import { useSync } from "@tui/context/sync"
+import { useProject } from "@tui/context/project"
+import { createMemo, createSignal, onMount } from "solid-js"
+import { setTimeout as sleep } from "node:timers/promises"
+import { useSDK } from "../context/sdk"
+import { useToast } from "../ui/toast"
+
+function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
+  return createOpencodeClient({
+    baseUrl: sdk.url,
+    fetch: sdk.fetch,
+    directory: sync.path.directory || sdk.directory,
+    experimental_workspaceID: workspaceID,
+  })
+}
+
+export async function openWorkspaceSession(input: {
+  dialog: ReturnType<typeof useDialog>
+  route: ReturnType<typeof useRoute>
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  toast: ReturnType<typeof useToast>
+  workspaceID: string
+}) {
+  const client = scoped(input.sdk, input.sync, input.workspaceID)
+  while (true) {
+    const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
+    if (!result) {
+      input.toast.show({
+        message: "Failed to create workspace session",
+        variant: "error",
+      })
+      return
+    }
+    if (result.response.status >= 500 && result.response.status < 600) {
+      await sleep(1000)
+      continue
+    }
+    if (!result.data) {
+      input.toast.show({
+        message: "Failed to create workspace session",
+        variant: "error",
+      })
+      return
+    }
+    input.route.navigate({
+      type: "session",
+      sessionID: result.data.id,
+    })
+    input.dialog.clear()
+    return
+  }
+}
+
+export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
+  const dialog = useDialog()
+  const sync = useSync()
+  const project = useProject()
+  const sdk = useSDK()
+  const toast = useToast()
+  const [creating, setCreating] = createSignal<string>()
+
+  onMount(() => {
+    dialog.setSize("medium")
+  })
+
+  const options = createMemo(() => {
+    const type = creating()
+    if (type) {
+      return [
+        {
+          title: `Creating ${type} workspace...`,
+          value: "creating" as const,
+          description: "This can take a while for remote environments",
+        },
+      ]
+    }
+    return [
+      {
+        title: "Worktree",
+        value: "worktree" as const,
+        description: "Create a local git worktree",
+      },
+    ]
+  })
+
+  const create = async (type: string) => {
+    if (creating()) return
+    setCreating(type)
+
+    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
+    const workspace = result?.data
+    if (!workspace) {
+      setCreating(undefined)
+      toast.show({
+        message: "Failed to create workspace",
+        variant: "error",
+      })
+      return
+    }
+    await project.workspace.sync()
+    await props.onSelect(workspace.id)
+    setCreating(undefined)
+  }
+
+  return (
+    <DialogSelect
+      title={creating() ? "Creating Workspace" : "New Workspace"}
+      skipFilter={true}
+      options={options()}
+      onSelect={(option) => {
+        if (option.value === "creating") return
+        void create(option.value)
+      }}
+    />
+  )
+}

+ 0 - 320
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx

@@ -1,320 +0,0 @@
-import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
-import { useRoute } from "@tui/context/route"
-import { useSync } from "@tui/context/sync"
-import { createEffect, createMemo, createSignal, onMount } from "solid-js"
-import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
-import { useSDK } from "../context/sdk"
-import { useToast } from "../ui/toast"
-import { useKeybind } from "../context/keybind"
-import { DialogSessionList } from "./workspace/dialog-session-list"
-import { setTimeout as sleep } from "node:timers/promises"
-
-function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
-  return createOpencodeClient({
-    baseUrl: sdk.url,
-    fetch: sdk.fetch,
-    directory: sync.data.path.directory || sdk.directory,
-    experimental_workspaceID: workspaceID,
-  })
-}
-
-async function openWorkspace(input: {
-  dialog: ReturnType<typeof useDialog>
-  route: ReturnType<typeof useRoute>
-  sdk: ReturnType<typeof useSDK>
-  sync: ReturnType<typeof useSync>
-  toast: ReturnType<typeof useToast>
-  workspaceID: string
-  forceCreate?: boolean
-}) {
-  const cacheSession = (session: Session) => {
-    input.sync.set(
-      "session",
-      [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
-        a.id.localeCompare(b.id),
-      ),
-    )
-  }
-
-  const client = scoped(input.sdk, input.sync, input.workspaceID)
-  const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
-  const session = listed?.data?.[0]
-  if (session?.id) {
-    cacheSession(session)
-    input.route.navigate({
-      type: "session",
-      sessionID: session.id,
-    })
-    input.dialog.clear()
-    return
-  }
-  let created: Session | undefined
-  while (!created) {
-    const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
-    if (!result) {
-      input.toast.show({
-        message: "Failed to open workspace",
-        variant: "error",
-      })
-      return
-    }
-    if (result.response.status >= 500 && result.response.status < 600) {
-      await sleep(1000)
-      continue
-    }
-    if (!result.data) {
-      input.toast.show({
-        message: "Failed to open workspace",
-        variant: "error",
-      })
-      return
-    }
-    created = result.data
-  }
-  cacheSession(created)
-  input.route.navigate({
-    type: "session",
-    sessionID: created.id,
-  })
-  input.dialog.clear()
-}
-
-function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
-  const dialog = useDialog()
-  const sync = useSync()
-  const sdk = useSDK()
-  const toast = useToast()
-  const [creating, setCreating] = createSignal<string>()
-
-  onMount(() => {
-    dialog.setSize("medium")
-  })
-
-  const options = createMemo(() => {
-    const type = creating()
-    if (type) {
-      return [
-        {
-          title: `Creating ${type} workspace...`,
-          value: "creating" as const,
-          description: "This can take a while for remote environments",
-        },
-      ]
-    }
-    return [
-      {
-        title: "Worktree",
-        value: "worktree" as const,
-        description: "Create a local git worktree",
-      },
-    ]
-  })
-
-  const createWorkspace = async (type: string) => {
-    if (creating()) return
-    setCreating(type)
-
-    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
-      console.log(err)
-      return undefined
-    })
-    console.log(JSON.stringify(result, null, 2))
-    const workspace = result?.data
-    if (!workspace) {
-      setCreating(undefined)
-      toast.show({
-        message: "Failed to create workspace",
-        variant: "error",
-      })
-      return
-    }
-    await sync.workspace.sync()
-    await props.onSelect(workspace.id)
-    setCreating(undefined)
-  }
-
-  return (
-    <DialogSelect
-      title={creating() ? "Creating Workspace" : "New Workspace"}
-      skipFilter={true}
-      options={options()}
-      onSelect={(option) => {
-        if (option.value === "creating") return
-        void createWorkspace(option.value)
-      }}
-    />
-  )
-}
-
-export function DialogWorkspaceList() {
-  const dialog = useDialog()
-  const route = useRoute()
-  const sync = useSync()
-  const sdk = useSDK()
-  const toast = useToast()
-  const keybind = useKeybind()
-  const [toDelete, setToDelete] = createSignal<string>()
-  const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
-
-  const open = (workspaceID: string, forceCreate?: boolean) =>
-    openWorkspace({
-      dialog,
-      route,
-      sdk,
-      sync,
-      toast,
-      workspaceID,
-      forceCreate,
-    })
-
-  async function selectWorkspace(workspaceID: string) {
-    if (workspaceID === "__local__") {
-      if (localCount() > 0) {
-        dialog.replace(() => <DialogSessionList localOnly={true} />)
-        return
-      }
-      route.navigate({
-        type: "home",
-      })
-      dialog.clear()
-      return
-    }
-    const count = counts()[workspaceID]
-    if (count && count > 0) {
-      dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
-      return
-    }
-
-    if (count === 0) {
-      await open(workspaceID)
-      return
-    }
-    const client = scoped(sdk, sync, workspaceID)
-    const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
-    if (listed?.data?.length) {
-      dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
-      return
-    }
-    await open(workspaceID)
-  }
-
-  const currentWorkspaceID = createMemo(() => {
-    if (route.data.type === "session") {
-      return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
-    }
-    return "__local__"
-  })
-
-  const localCount = createMemo(
-    () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
-  )
-
-  let run = 0
-  createEffect(() => {
-    const workspaces = sync.data.workspaceList
-    const next = ++run
-    if (!workspaces.length) {
-      setCounts({})
-      return
-    }
-    setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
-    void Promise.all(
-      workspaces.map(async (workspace) => {
-        const client = scoped(sdk, sync, workspace.id)
-        const result = await client.session.list({ roots: true }).catch(() => undefined)
-        return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
-      }),
-    ).then((entries) => {
-      if (run !== next) return
-      setCounts(Object.fromEntries(entries))
-    })
-  })
-
-  const options = createMemo(() => [
-    {
-      title: "Local",
-      value: "__local__",
-      category: "Workspace",
-      description: "Use the local machine",
-      footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
-    },
-    ...sync.data.workspaceList.map((workspace) => {
-      const count = counts()[workspace.id]
-      return {
-        title:
-          toDelete() === workspace.id
-            ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
-            : workspace.id,
-        value: workspace.id,
-        category: workspace.type,
-        description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
-        footer:
-          count === undefined
-            ? "Loading sessions..."
-            : count === null
-              ? "Sessions unavailable"
-              : `${count} session${count === 1 ? "" : "s"}`,
-      }
-    }),
-    {
-      title: "+ New workspace",
-      value: "__create__",
-      category: "Actions",
-      description: "Create a new workspace",
-    },
-  ])
-
-  onMount(() => {
-    dialog.setSize("large")
-    void sync.workspace.sync()
-  })
-
-  return (
-    <DialogSelect
-      title="Workspaces"
-      skipFilter={true}
-      options={options()}
-      current={currentWorkspaceID()}
-      onMove={() => {
-        setToDelete(undefined)
-      }}
-      onSelect={(option) => {
-        setToDelete(undefined)
-        if (option.value === "__create__") {
-          dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
-          return
-        }
-        void selectWorkspace(option.value)
-      }}
-      keybind={[
-        {
-          keybind: keybind.all.session_delete?.[0],
-          title: "delete",
-          onTrigger: async (option) => {
-            if (option.value === "__create__" || option.value === "__local__") return
-            if (toDelete() !== option.value) {
-              setToDelete(option.value)
-              return
-            }
-            const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
-            setToDelete(undefined)
-            if (result?.error) {
-              toast.show({
-                message: "Failed to delete workspace",
-                variant: "error",
-              })
-              return
-            }
-            if (currentWorkspaceID() === option.value) {
-              route.navigate({
-                type: "home",
-              })
-            }
-            await sync.workspace.sync()
-          },
-        },
-      ]}
-    />
-  )
-}

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -250,7 +250,7 @@ export function Autocomplete(props: {
         const width = props.anchor().width - 4
         options.push(
           ...sortedFiles.map((item): AutocompleteOption => {
-            const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
+            const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
             const fullPath = `${baseDir}/${item}`
             const urlObj = pathToFileURL(fullPath)
             let filename = item

+ 15 - 2
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
 import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
+import { useEvent } from "@tui/context/event"
 import { MessageID, PartID } from "@/session/schema"
 import { createStore, produce } from "solid-js/store"
 import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
   const agentStyleId = syntax().getStyleId("extmark.agent")!
   const pasteStyleId = syntax().getStyleId("extmark.paste")!
   let promptPartTypeId = 0
+  const event = useEvent()
 
-  sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
+  event.on(TuiEvent.PromptAppend.type, (evt) => {
     if (!input || input.isDestroyed) return
     input.insertText(evt.properties.text)
     setTimeout(() => {
@@ -587,6 +589,13 @@ export function Prompt(props: PromptProps) {
   ])
 
   async function submit() {
+    // IME: double-defer may fire before onContentChange flushes the last
+    // composed character (e.g. Korean hangul) to the store, so read
+    // plainText directly and sync before any downstream reads.
+    if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
+      setStore("prompt", "input", input.plainText)
+      syncExtmarksWithPromptParts()
+    }
     if (props.disabled) return
     if (autocomplete?.visible) return
     if (!store.prompt.input) return
@@ -992,7 +1001,11 @@ export function Prompt(props: PromptProps) {
                     input.cursorOffset = input.plainText.length
                 }
               }}
-              onSubmit={submit}
+              onSubmit={() => {
+                // IME: double-defer so the last composed character (e.g. Korean
+                // hangul) is flushed to plainText before we read it for submission.
+                setTimeout(() => setTimeout(() => submit(), 0), 0)
+              }}
               onPaste={async (event: PasteEvent) => {
                 if (props.disabled) {
                   event.preventDefault()

+ 0 - 151
packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx

@@ -1,151 +0,0 @@
-import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
-import { useRoute } from "@tui/context/route"
-import { useSync } from "@tui/context/sync"
-import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
-import { Locale } from "@/util/locale"
-import { useKeybind } from "../../context/keybind"
-import { useTheme } from "../../context/theme"
-import { useSDK } from "../../context/sdk"
-import { DialogSessionRename } from "../dialog-session-rename"
-import { useKV } from "../../context/kv"
-import { createDebouncedSignal } from "../../util/signal"
-import { Spinner } from "../spinner"
-import { useToast } from "../../ui/toast"
-
-export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
-  const dialog = useDialog()
-  const route = useRoute()
-  const sync = useSync()
-  const keybind = useKeybind()
-  const { theme } = useTheme()
-  const sdk = useSDK()
-  const kv = useKV()
-  const toast = useToast()
-  const [toDelete, setToDelete] = createSignal<string>()
-  const [search, setSearch] = createDebouncedSignal("", 150)
-
-  const [listed, listedActions] = createResource(
-    () => props.workspaceID,
-    async (workspaceID) => {
-      if (!workspaceID) return undefined
-      const result = await sdk.client.session.list({ roots: true })
-      return result.data ?? []
-    },
-  )
-
-  const [searchResults] = createResource(search, async (query) => {
-    if (!query || props.localOnly) return undefined
-    const result = await sdk.client.session.list({
-      search: query,
-      limit: 30,
-      ...(props.workspaceID ? { roots: true } : {}),
-    })
-    return result.data ?? []
-  })
-
-  const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
-
-  const sessions = createMemo(() => {
-    if (searchResults()) return searchResults()!
-    if (props.workspaceID) return listed() ?? []
-    if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
-    return sync.data.session
-  })
-
-  const options = createMemo(() => {
-    const today = new Date().toDateString()
-    return sessions()
-      .filter((x) => {
-        if (x.parentID !== undefined) return false
-        if (props.workspaceID && listed()) return true
-        if (props.workspaceID) return x.workspaceID === props.workspaceID
-        if (props.localOnly) return !x.workspaceID
-        return true
-      })
-      .toSorted((a, b) => b.time.updated - a.time.updated)
-      .map((x) => {
-        const date = new Date(x.time.updated)
-        let category = date.toDateString()
-        if (category === today) {
-          category = "Today"
-        }
-        const isDeleting = toDelete() === x.id
-        const status = sync.data.session_status?.[x.id]
-        const isWorking = status?.type === "busy"
-        return {
-          title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
-          bg: isDeleting ? theme.error : undefined,
-          value: x.id,
-          category,
-          footer: Locale.time(x.time.updated),
-          gutter: isWorking ? <Spinner /> : undefined,
-        }
-      })
-  })
-
-  onMount(() => {
-    dialog.setSize("large")
-  })
-
-  return (
-    <DialogSelect
-      title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
-      options={options()}
-      skipFilter={!props.localOnly}
-      current={currentSessionID()}
-      onFilter={setSearch}
-      onMove={() => {
-        setToDelete(undefined)
-      }}
-      onSelect={(option) => {
-        route.navigate({
-          type: "session",
-          sessionID: option.value,
-        })
-        dialog.clear()
-      }}
-      keybind={[
-        {
-          keybind: keybind.all.session_delete?.[0],
-          title: "delete",
-          onTrigger: async (option) => {
-            if (toDelete() === option.value) {
-              const deleted = await sdk.client.session
-                .delete({
-                  sessionID: option.value,
-                })
-                .then(() => true)
-                .catch(() => false)
-              setToDelete(undefined)
-              if (!deleted) {
-                toast.show({
-                  message: "Failed to delete session",
-                  variant: "error",
-                })
-                return
-              }
-              if (props.workspaceID) {
-                listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
-                return
-              }
-              sync.set(
-                "session",
-                sync.data.session.filter((session) => session.id !== option.value),
-              )
-              return
-            }
-            setToDelete(option.value)
-          },
-        },
-        {
-          keybind: keybind.all.session_rename?.[0],
-          title: "rename",
-          onTrigger: async (option) => {
-            dialog.replace(() => <DialogSessionRename session={option.value} />)
-          },
-        },
-      ]}
-    />
-  )
-}

+ 3 - 1
packages/opencode/src/cli/cmd/tui/context/directory.ts

@@ -1,11 +1,13 @@
 import { createMemo } from "solid-js"
+import { useProject } from "./project"
 import { useSync } from "./sync"
 import { Global } from "@/global"
 
 export function useDirectory() {
+  const project = useProject()
   const sync = useSync()
   return createMemo(() => {
-    const directory = sync.data.path.directory || process.cwd()
+    const directory = project.instance.path().directory || process.cwd()
     const result = directory.replace(Global.Path.home, "~")
     if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
     return result

+ 41 - 0
packages/opencode/src/cli/cmd/tui/context/event.ts

@@ -0,0 +1,41 @@
+import type { Event } from "@opencode-ai/sdk/v2"
+import { useProject } from "./project"
+import { useSDK } from "./sdk"
+
+export function useEvent() {
+  const project = useProject()
+  const sdk = useSDK()
+
+  function subscribe(handler: (event: Event) => void) {
+    return sdk.event.on("event", (event) => {
+      // Special hack for truly global events
+      if (event.directory === "global") {
+        handler(event.payload)
+      }
+
+      if (project.workspace.current()) {
+        if (event.workspace === project.workspace.current()) {
+          handler(event.payload)
+        }
+
+        return
+      }
+
+      if (event.directory === project.instance.directory()) {
+        handler(event.payload)
+      }
+    })
+  }
+
+  function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
+    return subscribe((event) => {
+      if (event.type !== type) return
+      handler(event as Extract<Event, { type: T }>)
+    })
+  }
+
+  return {
+    subscribe,
+    on,
+  }
+}

+ 106 - 0
packages/opencode/src/cli/cmd/tui/context/project.tsx

@@ -0,0 +1,106 @@
+import { batch } from "solid-js"
+import type { Path, Workspace } from "@opencode-ai/sdk/v2"
+import { createStore, reconcile } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+
+type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+
+export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
+  name: "Project",
+  init: () => {
+    const sdk = useSDK()
+    const [store, setStore] = createStore({
+      project: {
+        id: undefined as string | undefined,
+      },
+      instance: {
+        path: {
+          home: "",
+          state: "",
+          config: "",
+          worktree: "",
+          directory: sdk.directory ?? "",
+        } satisfies Path,
+      },
+      workspace: {
+        current: undefined as string | undefined,
+        list: [] as Workspace[],
+        status: {} as Record<string, WorkspaceStatus>,
+      },
+    })
+
+    async function sync() {
+      const workspace = store.workspace.current
+      const [path, project] = await Promise.all([
+        sdk.client.path.get({ workspace }),
+        sdk.client.project.current({ workspace }),
+      ])
+
+      batch(() => {
+        setStore("instance", "path", reconcile(path.data!))
+        setStore("project", "id", project.data?.id)
+      })
+    }
+
+    async function syncWorkspace() {
+      const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
+      if (!listed?.data) return
+      const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
+      const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
+
+      batch(() => {
+        setStore("workspace", "list", reconcile(listed.data))
+        setStore("workspace", "status", reconcile(next))
+        if (!listed.data.some((item) => item.id === store.workspace.current)) {
+          setStore("workspace", "current", undefined)
+        }
+      })
+    }
+
+    sdk.event.on("event", (event) => {
+      if (event.payload.type === "workspace.status") {
+        setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
+      }
+    })
+
+    return {
+      data: store,
+      project() {
+        return store.project.id
+      },
+      instance: {
+        path() {
+          return store.instance.path
+        },
+        directory() {
+          return store.instance.path.directory
+        },
+      },
+      workspace: {
+        current() {
+          return store.workspace.current
+        },
+        set(next?: string | null) {
+          const workspace = next ?? undefined
+          if (store.workspace.current === workspace) return
+          setStore("workspace", "current", workspace)
+        },
+        list() {
+          return store.workspace.list
+        },
+        get(workspaceID: string) {
+          return store.workspace.list.find((item) => item.id === workspaceID)
+        },
+        status(workspaceID: string) {
+          return store.workspace.status[workspaceID]
+        },
+        statuses() {
+          return store.workspace.status
+        },
+        sync: syncWorkspace,
+      },
+      sync,
+    }
+  },
+})

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

@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
 export type HomeRoute = {
   type: "home"
   initialPrompt?: PromptInfo
-  workspaceID?: string
 }
 
 export type SessionRoute = {

+ 9 - 8
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -1,10 +1,11 @@
-import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { batch, onCleanup, onMount } from "solid-js"
 
 export type EventSource = {
-  subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
+  subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
 }
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
     let sdk = createSDK()
 
     const emitter = createGlobalEmitter<{
-      [key in Event["type"]]: Extract<Event, { type: key }>
+      event: GlobalEvent
     }>()
 
-    let queue: Event[] = []
+    let queue: GlobalEvent[] = []
     let timer: Timer | undefined
     let last = 0
 
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       // Batch all event emissions so all store updates result in a single render
       batch(() => {
         for (const event of events) {
-          emitter.emit(event.type, event)
+          emitter.emit("event", event)
         }
       })
     }
 
-    const handleEvent = (event: Event) => {
+    const handleEvent = (event: GlobalEvent) => {
       queue.push(event)
       const elapsed = Date.now() - last
 
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       ;(async () => {
         while (true) {
           if (abort.signal.aborted || ctrl.signal.aborted) break
-          const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
+          const events = await sdk.global.event({ signal: ctrl.signal })
 
           for await (const event of events.stream) {
             if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
 
     onMount(async () => {
       if (props.events) {
-        const unsub = await props.events.subscribe(props.directory, handleEvent)
+        const unsub = await props.events.subscribe(handleEvent)
         onCleanup(unsub)
       } else {
         startSSE()

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

@@ -19,16 +19,16 @@ import type {
   VcsInfo,
 } from "@opencode-ai/sdk/v2"
 import { createStore, produce, reconcile } from "solid-js/store"
+import { useProject } from "@tui/context/project"
+import { useEvent } from "@tui/context/event"
 import { useSDK } from "@tui/context/sdk"
 import { Binary } from "@opencode-ai/util/binary"
 import { createSimpleContext } from "./helper"
 import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
 import { useArgs } from "./args"
-import { batch, onMount } from "solid-js"
+import { batch, createEffect, on } from "solid-js"
 import { Log } from "@/util/log"
-import type { Path } from "@opencode-ai/sdk"
-import type { Workspace } from "@opencode-ai/sdk/v2"
 import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -75,8 +75,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       }
       formatter: FormatterStatus[]
       vcs: VcsInfo | undefined
-      path: Path
-      workspaceList: Workspace[]
     }>({
       provider_next: {
         all: [],
@@ -104,20 +102,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       mcp_resource: {},
       formatter: [],
       vcs: undefined,
-      path: { state: "", config: "", worktree: "", directory: "" },
-      workspaceList: [],
     })
 
+    const event = useEvent()
+    const project = useProject()
     const sdk = useSDK()
 
-    async function syncWorkspaces() {
-      const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
-      if (!result?.data) return
-      setStore("workspaceList", reconcile(result.data))
-    }
-
-    sdk.event.listen((e) => {
-      const event = e.details
+    event.subscribe((event) => {
       switch (event.type) {
         case "server.instance.disposed":
           bootstrap()
@@ -344,7 +335,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         }
 
         case "lsp.updated": {
-          sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
+          const workspace = project.workspace.current()
+          sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
           break
         }
 
@@ -360,25 +352,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     async function bootstrap() {
       console.log("bootstrapping")
+      const workspace = project.workspace.current()
       const start = Date.now() - 30 * 24 * 60 * 60 * 1000
       const sessionListPromise = sdk.client.session
         .list({ start: start })
         .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
 
       // blocking - include session.list when continuing a session
-      const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
-      const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
+      const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
+      const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
       const consoleStatePromise = sdk.client.experimental.console
-        .get({}, { throwOnError: true })
+        .get({ workspace }, { throwOnError: true })
         .then((x) => ConsoleState.parse(x.data))
         .catch(() => emptyConsoleState)
-      const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
-      const configPromise = sdk.client.config.get({}, { throwOnError: true })
+      const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
+      const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
+      const projectPromise = project.sync()
       const blockingRequests: Promise<unknown>[] = [
         providersPromise,
         providerListPromise,
         agentsPromise,
         configPromise,
+        projectPromise,
         ...(args.continue ? [sessionListPromise] : []),
       ]
 
@@ -423,18 +418,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           Promise.all([
             ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
             consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
-            sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
-            sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
-            sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
-            sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
-            sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
-            sdk.client.session.status().then((x) => {
+            sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
+            sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
+            sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
+            sdk.client.experimental.resource
+              .list({ workspace })
+              .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
+            sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
+            sdk.client.session.status({ workspace }).then((x) => {
               setStore("session_status", reconcile(x.data!))
             }),
-            sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
-            sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
-            sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
-            syncWorkspaces(),
+            sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
+            sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
+            project.workspace.sync(),
           ]).then(() => {
             setStore("status", "complete")
           })
@@ -449,11 +445,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         })
     }
 
-    onMount(() => {
-      bootstrap()
-    })
-
     const fullSyncedSessions = new Set<string>()
+    createEffect(
+      on(
+        () => project.workspace.current(),
+        () => {
+          fullSyncedSessions.clear()
+          void bootstrap()
+        },
+      ),
+    )
+
     const result = {
       data: store,
       set: setStore,
@@ -463,6 +465,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       get ready() {
         return store.status !== "loading"
       },
+      get path() {
+        return project.instance.path()
+      },
       session: {
         get(sessionID: string) {
           const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +486,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         },
         async sync(sessionID: string) {
           if (fullSyncedSessions.has(sessionID)) return
+          const workspace = project.workspace.current()
           const [session, messages, todo, diff] = await Promise.all([
-            sdk.client.session.get({ sessionID }, { throwOnError: true }),
-            sdk.client.session.messages({ sessionID, limit: 100 }),
-            sdk.client.session.todo({ sessionID }),
-            sdk.client.session.diff({ sessionID }),
+            sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
+            sdk.client.session.messages({ sessionID, limit: 100, workspace }),
+            sdk.client.session.todo({ sessionID, workspace }),
+            sdk.client.session.diff({ sessionID, workspace }),
           ])
           setStore(
             produce((draft) => {
@@ -503,12 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           fullSyncedSessions.add(sessionID)
         },
       },
-      workspace: {
-        get(workspaceID: string) {
-          return store.workspaceList.find((workspace) => workspace.id === workspaceID)
-        },
-        sync: syncWorkspaces,
-      },
       bootstrap,
     }
     return result

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

@@ -1,6 +1,7 @@
 import type { ParsedKey } from "@opentui/core"
 import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
 import type { useCommandDialog } from "@tui/component/dialog-command"
+import type { useEvent } from "@tui/context/event"
 import type { useKeybind } from "@tui/context/keybind"
 import type { useRoute } from "@tui/context/route"
 import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
   route: ReturnType<typeof useRoute>
   routes: RouteMap
   bump: () => void
+  event: ReturnType<typeof useEvent>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
   theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
       return sync.data.provider
     },
     get path() {
-      return sync.data.path
+      return sync.path
     },
     get vcs() {
       if (!sync.data.vcs) return
@@ -144,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
         branch: sync.data.vcs.branch,
       }
     },
-    workspace: {
-      list() {
-        return sync.data.workspaceList
-      },
-      get(workspaceID) {
-        return sync.workspace.get(workspaceID)
-      },
-    },
     session: {
       count() {
         return sync.data.session.length
@@ -342,7 +336,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
     get client() {
       return input.sdk.client
     },
-    event: input.sdk.event,
+    event: input.event,
     renderer: input.renderer,
     slots: {
       register() {

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

@@ -1,6 +1,7 @@
 import { Prompt, type PromptRef } from "@tui/component/prompt"
 import { createEffect, createSignal } from "solid-js"
 import { Logo } from "../component/logo"
+import { useProject } from "../context/project"
 import { useSync } from "../context/sync"
 import { Toast } from "../ui/toast"
 import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
 
 export function Home() {
   const sync = useSync()
+  const project = useProject()
   const route = useRouteData("home")
   const promptRef = usePromptRef()
   const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
         </box>
         <box height={1} minHeight={0} flexShrink={1} />
         <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
-          <TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
+          <TuiPluginRuntime.Slot
+            name="home_prompt"
+            mode="replace"
+            workspace_id={project.workspace.current()}
+            ref={bind}
+          >
             <Prompt
               ref={bind}
-              workspaceID={route.workspaceID}
-              right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
+              workspaceID={project.workspace.current()}
+              right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
               placeholders={placeholder}
             />
           </TuiPluginRuntime.Slot>

+ 38 - 8
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -15,7 +15,9 @@ import {
 import { Dynamic } from "solid-js/web"
 import path from "path"
 import { useRoute, useRouteData } from "@tui/context/route"
+import { useProject } from "@tui/context/project"
 import { useSync } from "@tui/context/sync"
+import { useEvent } from "@tui/context/event"
 import { SplitBorder } from "@tui/component/border"
 import { Spinner } from "@tui/component/spinner"
 import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -83,9 +85,15 @@ import { UI } from "@/cli/ui.ts"
 import { useTuiConfig } from "../../context/tui-config"
 import { getScrollAcceleration } from "../../util/scroll"
 import { TuiPluginRuntime } from "../../plugin"
+import { DialogGoUpsell } from "../../component/dialog-go-upsell"
+import { SessionRetry } from "@/session/retry"
 
 addDefaultParsers(parsers.parsers)
 
+const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
+const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
+const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
+
 const context = createContext<{
   width: number
   sessionID: string
@@ -110,6 +118,8 @@ export function Session() {
   const route = useRouteData("session")
   const { navigate } = useRoute()
   const sync = useSync()
+  const event = useEvent()
+  const project = useProject()
   const tuiConfig = useTuiConfig()
   const kv = useKV()
   const { theme } = useTheme()
@@ -149,7 +159,7 @@ export function Session() {
   const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
   const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
   const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
-  const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
+  const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
   const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
   const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
   const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
@@ -166,10 +176,16 @@ export function Session() {
   const providers = createMemo(() => Model.index(sync.data.provider))
 
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
+  const toast = useToast()
+  const sdk = useSDK()
 
   createEffect(async () => {
-    await sync.session
-      .sync(route.sessionID)
+    await sdk.client.session
+      .get({ sessionID: route.sessionID }, { throwOnError: true })
+      .then((x) => {
+        project.workspace.set(x.data?.workspaceID)
+      })
+      .then(() => sync.session.sync(route.sessionID))
       .then(() => {
         if (scroll) scroll.scrollBy(100_000)
       })
@@ -183,13 +199,10 @@ export function Session() {
       })
   })
 
-  const toast = useToast()
-  const sdk = useSDK()
-
   // Handle initial prompt from fork
   let seeded = false
   let lastSwitch: string | undefined = undefined
-  sdk.event.on("message.part.updated", (evt) => {
+  event.on("message.part.updated", (evt) => {
     const part = evt.properties.part
     if (part.type !== "tool") return
     if (part.sessionID !== route.sessionID) return
@@ -218,6 +231,23 @@ export function Session() {
   const dialog = useDialog()
   const renderer = useRenderer()
 
+  event.on("session.status", (evt) => {
+    if (evt.properties.sessionID !== route.sessionID) return
+    if (evt.properties.status.type !== "retry") return
+    if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
+    if (dialog.stack.length > 0) return
+
+    const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
+    if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
+
+    if (kv.get(GO_UPSELL_DONT_SHOW)) return
+
+    DialogGoUpsell.show(dialog).then((dontShowAgain) => {
+      if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
+      kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
+    })
+  })
+
   // Allow exit when in child session (prompt is hidden)
   const exit = useExit()
 
@@ -1768,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
     const workdir = props.input.workdir
     if (!workdir || workdir === ".") return undefined
 
-    const base = sync.data.path.directory
+    const base = sync.path.directory
     if (!base) return undefined
 
     const absolute = path.resolve(base, workdir)

+ 12 - 14
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
 import { withTimeout } from "@/util/timeout"
 import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
-import type { Event } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "./context/sdk"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
 import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
 
 function createEventSource(client: RpcClient): EventSource {
   return {
-    subscribe: async (directory, handler) => {
-      const id = await client.call("subscribe", { directory })
-      const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
-        if (e.id === id) {
-          handler(e.event)
-        }
+    subscribe: async (handler) => {
+      return client.on<GlobalEvent>("global.event", (e) => {
+        handler(e)
       })
-
-      return () => {
-        unsub()
-        client.call("unsubscribe", { id })
-      }
     },
   }
 }
@@ -145,12 +137,18 @@ export const TuiThreadCommand = cmd({
         ),
       })
       worker.onerror = (e) => {
-        Log.Default.error(e)
+        Log.Default.error("thread error", {
+          message: e.message,
+          filename: e.filename,
+          lineno: e.lineno,
+          colno: e.colno,
+          error: e.error,
+        })
       }
 
       const client = Rpc.client<typeof rpc>(worker)
       const error = (e: unknown) => {
-        Log.Default.error(e)
+        Log.Default.error("process error", { error: errorMessage(e) })
       }
       const reload = () => {
         client.call("reload", undefined).catch((err) => {

+ 42 - 11
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
   keybind?: {
     keybind?: Keybind.Info
     title: string
+    side?: "left" | "right"
     disabled?: boolean
     onTrigger: (option: DialogSelectOption<T>) => void
   }[]
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
   disabled?: boolean
   bg?: RGBA
   gutter?: JSX.Element
+  margin?: JSX.Element
   onSelect?: (ctx: DialogContext) => void
 }
 
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   props.ref?.(ref)
 
   const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
+  const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
+  const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
 
   return (
     <box gap={1} paddingBottom={1}>
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                       <box
                         id={JSON.stringify(option.value)}
                         flexDirection="row"
+                        position="relative"
                         onMouseMove={() => {
                           setStore("input", "mouse")
                         }}
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                         paddingRight={3}
                         gap={1}
                       >
+                        <Show when={!current() && option.margin}>
+                          <box position="absolute" left={1} flexShrink={0}>
+                            {option.margin}
+                          </box>
+                        </Show>
                         <Option
                           title={option.title}
                           footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
         </scrollbox>
       </Show>
       <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
-        <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
-          <For each={keybinds()}>
-            {(item) => (
-              <text>
-                <span style={{ fg: theme.text }}>
-                  <b>{item.title}</b>{" "}
-                </span>
-                <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
-              </text>
-            )}
-          </For>
+        <box
+          paddingRight={2}
+          paddingLeft={4}
+          flexDirection="row"
+          justifyContent="space-between"
+          flexShrink={0}
+          paddingTop={1}
+        >
+          <box flexDirection="row" gap={2}>
+            <For each={left()}>
+              {(item) => (
+                <text>
+                  <span style={{ fg: theme.text }}>
+                    <b>{item.title}</b>{" "}
+                  </span>
+                  <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+                </text>
+              )}
+            </For>
+          </box>
+          <box flexDirection="row" gap={2}>
+            <For each={right()}>
+              {(item) => (
+                <text>
+                  <span style={{ fg: theme.text }}>
+                    <b>{item.title}</b>{" "}
+                  </span>
+                  <span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
+                </text>
+              )}
+            </For>
+          </box>
         </box>
       </Show>
     </box>

+ 2 - 96
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
 import { Rpc } from "@/util/rpc"
 import { upgrade } from "@/cli/upgrade"
 import { Config } from "@/config/config"
-import { Bus } from "@/bus"
 import { GlobalBus } from "@/bus/global"
-import type { Event } from "@opencode-ai/sdk/v2"
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import { Flag } from "@/flag/flag"
-import { setTimeout as sleep } from "node:timers/promises"
 import { writeHeapSnapshot } from "node:v8"
-import { WorkspaceID } from "@/control-plane/schema"
 import { Heap } from "@/cli/heap"
 
 await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
 
 let server: Awaited<ReturnType<typeof Server.listen>> | undefined
 
-const eventStreams = new Map<string, AbortController>()
-
-function startEventStream(directory: string) {
-  const id = crypto.randomUUID()
-
-  const abort = new AbortController()
-  const signal = abort.signal
-
-  eventStreams.set(id, abort)
-
-  async function run() {
-    while (!signal.aborted) {
-      const shouldReconnect = await Instance.provide({
-        directory,
-        init: InstanceBootstrap,
-        fn: () =>
-          new Promise<boolean>((resolve) => {
-            Rpc.emit("event", {
-              type: "server.connected",
-              properties: {},
-            } satisfies Event)
-
-            let settled = false
-            const settle = (value: boolean) => {
-              if (settled) return
-              settled = true
-              signal.removeEventListener("abort", onAbort)
-              unsub()
-              resolve(value)
-            }
-
-            const unsub = Bus.subscribeAll((event) => {
-              Rpc.emit("event", {
-                id,
-                event: event as Event,
-              })
-              if (event.type === Bus.InstanceDisposed.type) {
-                settle(true)
-              }
-            })
-
-            const onAbort = () => {
-              settle(false)
-            }
-
-            signal.addEventListener("abort", onAbort, { once: true })
-          }),
-      }).catch((error) => {
-        Log.Default.error("event stream subscribe error", {
-          error: error instanceof Error ? error.message : error,
-        })
-        return false
-      })
-
-      if (!shouldReconnect || signal.aborted) {
-        break
-      }
-
-      if (!signal.aborted) {
-        await sleep(250)
-      }
-    }
-  }
-
-  run().catch((error) => {
-    Log.Default.error("event stream error", {
-      error: error instanceof Error ? error.message : error,
-    })
-  })
-
-  return id
-}
-
-function stopEventStream(id: string) {
-  const abortController = eventStreams.get(id)
-  if (!abortController) return
-
-  abortController.abort()
-  eventStreams.delete(id)
-}
-
 export const rpc = {
   async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
     const headers = { ...input.headers }
@@ -138,7 +54,7 @@ export const rpc = {
       headers,
       body: input.body,
     })
-    const response = await Server.Default().fetch(request)
+    const response = await Server.Default().app.fetch(request)
     const body = await response.text()
     return {
       status: response.status,
@@ -167,19 +83,9 @@ export const rpc = {
   async reload() {
     await Config.invalidate(true)
   },
-  async subscribe(input: { directory: string | undefined }) {
-    return startEventStream(input.directory || process.cwd())
-  },
-  async unsubscribe(input: { id: string }) {
-    stopEventStream(input.id)
-  },
   async shutdown() {
     Log.Default.info("worker shutting down")
 
-    for (const id of [...eventStreams.keys()]) {
-      stopEventStream(id)
-    }
-
     await Instance.disposeAll()
     if (server) await server.stop(true)
   },

+ 2 - 1
packages/opencode/src/cli/cmd/uninstall.ts

@@ -1,6 +1,7 @@
 import type { Argv } from "yargs"
 import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "../../installation"
 import { Global } from "../../global"
 import fs from "fs/promises"
@@ -57,7 +58,7 @@ export const UninstallCommand = {
     UI.empty()
     prompts.intro("Uninstall OpenCode")
 
-    const method = await Installation.method()
+    const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
     prompts.log.info(`Installation method: ${method}`)
 
     const targets = await collectRemovalTargets(args, method)

+ 8 - 3
packages/opencode/src/cli/cmd/upgrade.ts

@@ -1,6 +1,7 @@
 import type { Argv } from "yargs"
 import { UI } from "../ui"
 import * as prompts from "@clack/prompts"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "../../installation"
 
 export const UpgradeCommand = {
@@ -24,7 +25,7 @@ export const UpgradeCommand = {
     UI.println(UI.logo("  "))
     UI.empty()
     prompts.intro("Upgrade")
-    const detectedMethod = await Installation.method()
+    const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
     const method = (args.method as Installation.Method) ?? detectedMethod
     if (method === "unknown") {
       prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -42,7 +43,9 @@ export const UpgradeCommand = {
       }
     }
     prompts.log.info("Using method: " + method)
-    const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
+    const target = args.target
+      ? args.target.replace(/^v/, "")
+      : await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
 
     if (Installation.VERSION === target) {
       prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -53,7 +56,9 @@ export const UpgradeCommand = {
     prompts.log.info(`From ${Installation.VERSION} → ${target}`)
     const spinner = prompts.spinner()
     spinner.start("Upgrading...")
-    const err = await Installation.upgrade(method, target).catch((err) => err)
+    const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
+      (err) => err,
+    )
     if (err) {
       spinner.stop("Upgrade failed", 1)
       if (err instanceof Installation.UpgradeFailedError) {

+ 4 - 3
packages/opencode/src/cli/upgrade.ts

@@ -1,12 +1,13 @@
 import { Bus } from "@/bus"
 import { Config } from "@/config/config"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Flag } from "@/flag/flag"
 import { Installation } from "@/installation"
 
 export async function upgrade() {
   const config = await Config.getGlobal()
-  const method = await Installation.method()
-  const latest = await Installation.latest(method).catch(() => {})
+  const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
+  const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
   if (!latest) return
 
   if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -25,7 +26,7 @@ export async function upgrade() {
   }
 
   if (method === "unknown") return
-  await Installation.upgrade(method, latest)
+  await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
     .then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
     .catch(() => {})
 }

+ 6 - 10
packages/opencode/src/command/index.ts

@@ -1,8 +1,9 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
+import type { InstanceContext } from "@/project/instance"
 import { SessionID, MessageID } from "@/session/schema"
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, Context } from "effect"
+import { EffectLogger } from "@/effect/logger"
 import z from "zod"
 import { Config } from "../config/config"
 import { MCP } from "../mcp"
@@ -70,7 +71,7 @@ export namespace Command {
     readonly list: () => Effect.Effect<Info[]>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
 
   export const layer = Layer.effect(
     Service,
@@ -79,7 +80,7 @@ export namespace Command {
       const mcp = yield* MCP.Service
       const skill = yield* Skill.Service
 
-      const init = Effect.fn("Command.state")(function* (ctx) {
+      const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
         const cfg = yield* config.get()
         const commands: Record<string, Info> = {}
 
@@ -140,6 +141,7 @@ export namespace Command {
                           .map((message) => (message.content.type === "text" ? message.content.text : ""))
                           .join("\n") || "",
                     ),
+                    Effect.provide(EffectLogger.layer),
                   ),
               )
             },
@@ -186,10 +188,4 @@ export namespace Command {
     Layer.provide(MCP.defaultLayer),
     Layer.provide(Skill.defaultLayer),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function list() {
-    return runPromise((svc) => svc.list())
-  }
 }

+ 102 - 38
packages/opencode/src/config/config.ts

@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
 import os from "os"
 import { Process } from "../util/process"
 import z from "zod"
-import { ModelsDev } from "../provider/models"
 import { mergeDeep, pipe, unique } from "remeda"
 import { Global } from "../global"
 import fsNode from "fs/promises"
@@ -37,10 +36,11 @@ import type { ConsoleState } from "./console-state"
 import { AppFileSystem } from "@/filesystem"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
-import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
+import { Duration, Effect, Layer, Option, Context } from "effect"
 import { Flock } from "@/util/flock"
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
 import { Npm } from "@/npm"
+import { InstanceRef } from "@/effect/instance-ref"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -399,6 +399,10 @@ export namespace Config {
         .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
       clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
       scope: z.string().optional().describe("OAuth scopes to request during authorization"),
+      redirectUri: z
+        .string()
+        .optional()
+        .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
     })
     .strict()
     .meta({
@@ -786,28 +790,81 @@ export namespace Config {
   })
   export type Layout = z.infer<typeof Layout>
 
-  export const Provider = ModelsDev.Provider.partial()
-    .extend({
-      whitelist: z.array(z.string()).optional(),
-      blacklist: z.array(z.string()).optional(),
-      models: z
+  export const Model = z
+    .object({
+      id: z.string(),
+      name: z.string(),
+      family: z.string().optional(),
+      release_date: z.string(),
+      attachment: z.boolean(),
+      reasoning: z.boolean(),
+      temperature: z.boolean(),
+      tool_call: z.boolean(),
+      interleaved: z
+        .union([
+          z.literal(true),
+          z
+            .object({
+              field: z.enum(["reasoning_content", "reasoning_details"]),
+            })
+            .strict(),
+        ])
+        .optional(),
+      cost: z
+        .object({
+          input: z.number(),
+          output: z.number(),
+          cache_read: z.number().optional(),
+          cache_write: z.number().optional(),
+          context_over_200k: z
+            .object({
+              input: z.number(),
+              output: z.number(),
+              cache_read: z.number().optional(),
+              cache_write: z.number().optional(),
+            })
+            .optional(),
+        })
+        .optional(),
+      limit: z.object({
+        context: z.number(),
+        input: z.number().optional(),
+        output: z.number(),
+      }),
+      modalities: z
+        .object({
+          input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
+          output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
+        })
+        .optional(),
+      experimental: z.boolean().optional(),
+      status: z.enum(["alpha", "beta", "deprecated"]).optional(),
+      provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
+      options: z.record(z.string(), z.any()),
+      headers: z.record(z.string(), z.string()).optional(),
+      variants: z
         .record(
           z.string(),
-          ModelsDev.Model.partial().extend({
-            variants: z
-              .record(
-                z.string(),
-                z
-                  .object({
-                    disabled: z.boolean().optional().describe("Disable this variant for the model"),
-                  })
-                  .catchall(z.any()),
-              )
-              .optional()
-              .describe("Variant-specific configuration"),
-          }),
+          z
+            .object({
+              disabled: z.boolean().optional().describe("Disable this variant for the model"),
+            })
+            .catchall(z.any()),
         )
-        .optional(),
+        .optional()
+        .describe("Variant-specific configuration"),
+    })
+    .partial()
+
+  export const Provider = z
+    .object({
+      api: z.string().optional(),
+      name: z.string(),
+      env: z.array(z.string()),
+      id: z.string(),
+      npm: z.string().optional(),
+      whitelist: z.array(z.string()).optional(),
+      blacklist: z.array(z.string()).optional(),
       options: z
         .object({
           apiKey: z.string().optional(),
@@ -840,11 +897,14 @@ export namespace Config {
         })
         .catchall(z.any())
         .optional(),
+      models: z.record(z.string(), Model).optional(),
     })
+    .partial()
     .strict()
     .meta({
       ref: "ProviderConfig",
     })
+
   export type Provider = z.infer<typeof Provider>
 
   export const Info = z
@@ -1066,7 +1126,7 @@ export namespace Config {
     readonly waitForDependencies: () => Effect.Effect<void>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
 
   function globalConfigFile() {
     const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@@ -1267,27 +1327,31 @@ export namespace Config {
           const consoleManagedProviders = new Set<string>()
           let activeOrgName: string | undefined
 
-          const scope = (source: string): PluginScope => {
+          const scope = Effect.fnUntraced(function* (source: string) {
             if (source.startsWith("http://") || source.startsWith("https://")) return "global"
             if (source === "OPENCODE_CONFIG_CONTENT") return "local"
-            if (Instance.containsPath(source)) return "local"
+            if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
             return "global"
-          }
+          })
 
-          const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
+          const track = Effect.fnUntraced(function* (
+            source: string,
+            list: PluginSpec[] | undefined,
+            kind?: PluginScope,
+          ) {
             if (!list?.length) return
-            const hit = kind ?? scope(source)
+            const hit = kind ?? (yield* scope(source))
             const plugins = deduplicatePluginOrigins([
               ...(result.plugin_origins ?? []),
               ...list.map((spec) => ({ spec, source, scope: hit })),
             ])
             result.plugin = plugins.map((item) => item.spec)
             result.plugin_origins = plugins
-          }
+          })
 
           const merge = (source: string, next: Info, kind?: PluginScope) => {
             result = mergeConfigConcatArrays(result, next)
-            track(source, next.plugin, kind)
+            return track(source, next.plugin, kind)
           }
 
           for (const [key, value] of Object.entries(auth)) {
@@ -1307,16 +1371,16 @@ export namespace Config {
                 dir: path.dirname(source),
                 source,
               })
-              merge(source, next, "global")
+              yield* merge(source, next, "global")
               log.debug("loaded remote config from well-known", { url })
             }
           }
 
           const global = yield* getGlobal()
-          merge(Global.Path.config, global, "global")
+          yield* merge(Global.Path.config, global, "global")
 
           if (Flag.OPENCODE_CONFIG) {
-            merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
+            yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
             log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
           }
 
@@ -1324,7 +1388,7 @@ export namespace Config {
             for (const file of yield* Effect.promise(() =>
               ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
             )) {
-              merge(file, yield* loadFile(file), "local")
+              yield* merge(file, yield* loadFile(file), "local")
             }
           }
 
@@ -1345,7 +1409,7 @@ export namespace Config {
               for (const file of ["opencode.json", "opencode.jsonc"]) {
                 const source = path.join(dir, file)
                 log.debug(`loading config from ${source}`)
-                merge(source, yield* loadFile(source))
+                yield* merge(source, yield* loadFile(source))
                 result.agent ??= {}
                 result.mode ??= {}
                 result.plugin ??= []
@@ -1364,7 +1428,7 @@ export namespace Config {
             result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
             result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
             const list = yield* Effect.promise(() => loadPlugin(dir))
-            track(dir, list)
+            yield* track(dir, list)
           }
 
           if (process.env.OPENCODE_CONFIG_CONTENT) {
@@ -1373,7 +1437,7 @@ export namespace Config {
               dir: ctx.directory,
               source,
             })
-            merge(source, next, "local")
+            yield* merge(source, next, "local")
             log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
           }
 
@@ -1402,7 +1466,7 @@ export namespace Config {
                 for (const providerID of Object.keys(next.provider ?? {})) {
                   consoleManagedProviders.add(providerID)
                 }
-                merge(source, next, "global")
+                yield* merge(source, next, "global")
               }
             }).pipe(
               Effect.catch((err) => {
@@ -1417,7 +1481,7 @@ export namespace Config {
           if (existsSync(managedDir)) {
             for (const file of ["opencode.json", "opencode.jsonc"]) {
               const source = path.join(managedDir, file)
-              merge(source, yield* loadFile(source), "global")
+              yield* merge(source, yield* loadFile(source), "global")
             }
           }
 

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

@@ -10,8 +10,7 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
 
 export const WorkspaceID = workspaceIdSchema.pipe(
   withStatics((schema: typeof workspaceIdSchema) => ({
-    make: (id: string) => schema.makeUnsafe(id),
-    ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
+    ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
     zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
   })),
 )

+ 22 - 0
packages/opencode/src/control-plane/workspace-context.ts

@@ -0,0 +1,22 @@
+import { LocalContext } from "../util/local-context"
+import type { WorkspaceID } from "../control-plane/schema"
+
+export interface WorkspaceContext {
+  workspaceID: string
+}
+
+const context = LocalContext.create<WorkspaceContext>("instance")
+
+export const WorkspaceContext = {
+  async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
+    return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
+  },
+
+  get workspaceID() {
+    try {
+      return context.use().workspaceID
+    } catch (err) {
+      return undefined
+    }
+  },
+}

+ 102 - 41
packages/opencode/src/control-plane/workspace.ts

@@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db"
 import { Project } from "@/project/project"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
+import { SyncEvent } from "@/sync"
 import { Log } from "@/util/log"
+import { Filesystem } from "@/util/filesystem"
 import { ProjectID } from "@/project/schema"
 import { WorkspaceTable } from "./workspace.sql"
 import { getAdaptor } from "./adaptors"
@@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema"
 import { parseSSE } from "./sse"
 
 export namespace Workspace {
+  export const Info = WorkspaceInfo.meta({
+    ref: "Workspace",
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const ConnectionStatus = z.object({
+    workspaceID: WorkspaceID.zod,
+    status: z.enum(["connected", "connecting", "disconnected", "error"]),
+    error: z.string().optional(),
+  })
+  export type ConnectionStatus = z.infer<typeof ConnectionStatus>
+
   export const Event = {
     Ready: BusEvent.define(
       "workspace.ready",
@@ -27,13 +41,9 @@ export namespace Workspace {
         message: z.string(),
       }),
     ),
+    Status: BusEvent.define("workspace.status", ConnectionStatus),
   }
 
-  export const Info = WorkspaceInfo.meta({
-    ref: "Workspace",
-  })
-  export type Info = z.infer<typeof Info>
-
   function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
     return {
       id: row.id,
@@ -85,6 +95,9 @@ export namespace Workspace {
     })
 
     await adaptor.create(config)
+
+    startSync(info)
+
     return info
   })
 
@@ -92,18 +105,24 @@ export namespace Workspace {
     const rows = Database.use((db) =>
       db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
     )
-    return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
+    const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
+    for (const space of spaces) startSync(space)
+    return spaces
   }
 
   export const get = fn(WorkspaceID.zod, async (id) => {
     const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
     if (!row) return
-    return fromRow(row)
+    const space = fromRow(row)
+    startSync(space)
+    return space
   })
 
   export const remove = fn(WorkspaceID.zod, async (id) => {
     const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
     if (row) {
+      stopSync(id)
+
       const info = fromRow(row)
       const adaptor = await getAdaptor(row.type)
       adaptor.remove(info)
@@ -111,58 +130,100 @@ export namespace Workspace {
       return info
     }
   })
+
+  const connections = new Map<WorkspaceID, ConnectionStatus>()
+  const aborts = new Map<WorkspaceID, AbortController>()
+
+  function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
+    const prev = connections.get(id)
+    if (prev?.status === status && prev?.error === error) return
+    const next = { workspaceID: id, status, error }
+    connections.set(id, next)
+    GlobalBus.emit("event", {
+      directory: "global",
+      workspace: id,
+      payload: {
+        type: Event.Status.type,
+        properties: next,
+      },
+    })
+  }
+
+  export function status(): ConnectionStatus[] {
+    return [...connections.values()]
+  }
+
   const log = Log.create({ service: "workspace-sync" })
 
-  async function workspaceEventLoop(space: Info, stop: AbortSignal) {
-    while (!stop.aborted) {
-      const adaptor = await getAdaptor(space.type)
-      const target = await Promise.resolve(adaptor.target(space))
+  async function workspaceEventLoop(space: Info, signal: AbortSignal) {
+    log.info("starting sync: " + space.id)
 
-      if (target.type === "local") {
-        return
-      }
+    while (!signal.aborted) {
+      log.info("connecting to sync: " + space.id)
 
-      const baseURL = String(target.url).replace(/\/?$/, "/")
+      setStatus(space.id, "connecting")
+      const adaptor = await getAdaptor(space.type)
+      const target = await adaptor.target(space)
+
+      if (target.type === "local") return
 
-      const res = await fetch(new URL(baseURL + "/event"), {
-        method: "GET",
-        signal: stop,
+      const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
+        setStatus(space.id, "error", String(err))
+        return undefined
       })
+      if (!res || !res.ok || !res.body) {
+        log.info("failed to connect to sync: " + res?.status)
 
-      if (!res.ok || !res.body) {
+        setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
         await sleep(1000)
         continue
       }
-
-      await parseSSE(res.body, stop, (event) => {
-        GlobalBus.emit("event", {
-          directory: space.id,
-          payload: event,
-        })
+      setStatus(space.id, "connected")
+      await parseSSE(res.body, signal, (evt) => {
+        const event = evt as SyncEvent.SerializedEvent
+
+        try {
+          if (!event.type.startsWith("server.")) {
+            SyncEvent.replay(event)
+          }
+        } catch (err) {
+          log.warn("failed to replay sync event", {
+            workspaceID: space.id,
+            error: err,
+          })
+        }
       })
-
-      // Wait 250ms and retry if SSE connection fails
+      setStatus(space.id, "disconnected")
+      log.info("disconnected to sync: " + space.id)
       await sleep(250)
     }
   }
 
-  export function startSyncing(project: Project.Info) {
-    const stop = new AbortController()
-    const spaces = list(project).filter((space) => space.type !== "worktree")
+  function startSync(space: Info) {
+    if (space.type === "worktree") {
+      void Filesystem.exists(space.directory!).then((exists) => {
+        setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
+      })
+      return
+    }
 
-    spaces.forEach((space) => {
-      void workspaceEventLoop(space, stop.signal).catch((error) => {
-        log.warn("workspace sync listener failed", {
-          workspaceID: space.id,
-          error,
-        })
+    if (aborts.has(space.id)) return
+    const abort = new AbortController()
+    aborts.set(space.id, abort)
+    setStatus(space.id, "disconnected")
+
+    void workspaceEventLoop(space, abort.signal).catch((error) => {
+      setStatus(space.id, "error", String(error))
+      log.warn("workspace sync listener failed", {
+        workspaceID: space.id,
+        error,
       })
     })
+  }
 
-    return {
-      async stop() {
-        stop.abort()
-      },
-    }
+  function stopSync(id: WorkspaceID) {
+    aborts.get(id)?.abort()
+    aborts.delete(id)
+    connections.delete(id)
   }
 }

+ 100 - 0
packages/opencode/src/effect/app-runtime.ts

@@ -0,0 +1,100 @@
+import { Layer, ManagedRuntime } from "effect"
+import { memoMap } from "./run-service"
+import { Observability } from "./oltp"
+
+import { AppFileSystem } from "@/filesystem"
+import { Bus } from "@/bus"
+import { Auth } from "@/auth"
+import { Account } from "@/account"
+import { Config } from "@/config/config"
+import { Git } from "@/git"
+import { Ripgrep } from "@/file/ripgrep"
+import { FileTime } from "@/file/time"
+import { File } from "@/file"
+import { FileWatcher } from "@/file/watcher"
+import { Storage } from "@/storage/storage"
+import { Snapshot } from "@/snapshot"
+import { Plugin } from "@/plugin"
+import { Provider } from "@/provider/provider"
+import { ProviderAuth } from "@/provider/auth"
+import { Agent } from "@/agent/agent"
+import { Skill } from "@/skill"
+import { Discovery } from "@/skill/discovery"
+import { Question } from "@/question"
+import { Permission } from "@/permission"
+import { Todo } from "@/session/todo"
+import { Session } from "@/session"
+import { SessionStatus } from "@/session/status"
+import { SessionRunState } from "@/session/run-state"
+import { SessionProcessor } from "@/session/processor"
+import { SessionCompaction } from "@/session/compaction"
+import { SessionRevert } from "@/session/revert"
+import { SessionSummary } from "@/session/summary"
+import { SessionPrompt } from "@/session/prompt"
+import { Instruction } from "@/session/instruction"
+import { LLM } from "@/session/llm"
+import { LSP } from "@/lsp"
+import { MCP } from "@/mcp"
+import { McpAuth } from "@/mcp/auth"
+import { Command } from "@/command"
+import { Truncate } from "@/tool/truncate"
+import { ToolRegistry } from "@/tool/registry"
+import { Format } from "@/format"
+import { Project } from "@/project/project"
+import { Vcs } from "@/project/vcs"
+import { Worktree } from "@/worktree"
+import { Pty } from "@/pty"
+import { Installation } from "@/installation"
+import { ShareNext } from "@/share/share-next"
+import { SessionShare } from "@/share/session"
+
+export const AppLayer = Layer.mergeAll(
+  Observability.layer,
+  AppFileSystem.defaultLayer,
+  Bus.defaultLayer,
+  Auth.defaultLayer,
+  Account.defaultLayer,
+  Config.defaultLayer,
+  Git.defaultLayer,
+  Ripgrep.defaultLayer,
+  FileTime.defaultLayer,
+  File.defaultLayer,
+  FileWatcher.defaultLayer,
+  Storage.defaultLayer,
+  Snapshot.defaultLayer,
+  Plugin.defaultLayer,
+  Provider.defaultLayer,
+  ProviderAuth.defaultLayer,
+  Agent.defaultLayer,
+  Skill.defaultLayer,
+  Discovery.defaultLayer,
+  Question.defaultLayer,
+  Permission.defaultLayer,
+  Todo.defaultLayer,
+  Session.defaultLayer,
+  SessionStatus.defaultLayer,
+  SessionRunState.defaultLayer,
+  SessionProcessor.defaultLayer,
+  SessionCompaction.defaultLayer,
+  SessionRevert.defaultLayer,
+  SessionSummary.defaultLayer,
+  SessionPrompt.defaultLayer,
+  Instruction.defaultLayer,
+  LLM.defaultLayer,
+  LSP.defaultLayer,
+  MCP.defaultLayer,
+  McpAuth.defaultLayer,
+  Command.defaultLayer,
+  Truncate.defaultLayer,
+  ToolRegistry.defaultLayer,
+  Format.defaultLayer,
+  Project.defaultLayer,
+  Vcs.defaultLayer,
+  Worktree.defaultLayer,
+  Pty.defaultLayer,
+  Installation.defaultLayer,
+  ShareNext.defaultLayer,
+  SessionShare.defaultLayer,
+)
+
+export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })

+ 10 - 0
packages/opencode/src/effect/bootstrap-runtime.ts

@@ -0,0 +1,10 @@
+import { Layer, ManagedRuntime } from "effect"
+import { memoMap } from "./run-service"
+
+import { FileWatcher } from "@/file/watcher"
+import { Format } from "@/format"
+import { ShareNext } from "@/share/share-next"
+
+export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
+
+export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })

+ 13 - 1
packages/opencode/src/effect/cross-spawn-spawner.ts

@@ -402,6 +402,7 @@ export const make = Effect.gen(function* () {
 
           const fd = yield* setupFds(command, proc, extra)
           const out = setupOutput(command, proc, sout, serr)
+          let ref = true
           return makeHandle({
             pid: ProcessId(proc.pid!),
             stdin: yield* setupStdin(command, proc, sin),
@@ -432,6 +433,18 @@ export const make = Effect.gen(function* () {
                 orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
               })
             },
+            unref: Effect.sync(() => {
+              if (ref) {
+                proc.unref()
+                ref = false
+              }
+              return Effect.sync(() => {
+                if (!ref) {
+                  proc.ref()
+                  ref = true
+                }
+              })
+            }),
           })
         }
         case "PipedCommand": {
@@ -499,4 +512,3 @@ const rt = lazy(async () => {
 
 type RT = Awaited<ReturnType<typeof rt>>
 export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
-export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))

+ 6 - 2
packages/opencode/src/effect/instance-ref.ts

@@ -1,6 +1,10 @@
-import { ServiceMap } from "effect"
+import { Context } from "effect"
 import type { InstanceContext } from "@/project/instance"
 
-export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
+export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
+  defaultValue: () => undefined,
+})
+
+export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
   defaultValue: () => undefined,
 })

+ 14 - 12
packages/opencode/src/effect/instance-state.ts

@@ -1,8 +1,10 @@
-import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
+import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
+import { EffectLogger } from "@/effect/logger"
 import { Instance, type InstanceContext } from "@/project/instance"
-import { Context } from "@/util/context"
-import { InstanceRef } from "./instance-ref"
+import { LocalContext } from "@/util/local-context"
+import { InstanceRef, WorkspaceRef } from "./instance-ref"
 import { registerDisposer } from "./instance-registry"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
 
 const TypeId = "~opencode/InstanceState"
 
@@ -16,10 +18,10 @@ export namespace InstanceState {
     try {
       return Instance.bind(fn)
     } catch (err) {
-      if (!(err instanceof Context.NotFound)) throw err
+      if (!(err instanceof LocalContext.NotFound)) throw err
     }
     const fiber = Fiber.getCurrent()
-    const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
+    const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
     if (!ctx) return fn
     return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
   }
@@ -28,6 +30,10 @@ export namespace InstanceState {
     return (yield* InstanceRef) ?? Instance.current
   })
 
+  export const workspaceID = Effect.gen(function* () {
+    return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
+  })
+
   export const directory = Effect.map(context, (ctx) => ctx.directory)
 
   export const make = <A, E = never, R = never>(
@@ -42,7 +48,9 @@ export namespace InstanceState {
           }),
       })
 
-      const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
+      const off = registerDisposer((directory) =>
+        Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
+      )
       yield* Effect.addFinalizer(() => Effect.sync(off))
 
       return {
@@ -73,10 +81,4 @@ export namespace InstanceState {
     Effect.gen(function* () {
       return yield* ScopedCache.invalidate(self.cache, yield* directory)
     })
-
-  /**
-   * Effect finalizers run on the fiber scheduler after the original async
-   * boundary, so ALS reads like Instance.directory can be gone by then.
-   */
-  export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
 }

+ 67 - 0
packages/opencode/src/effect/logger.ts

@@ -0,0 +1,67 @@
+import { Cause, Effect, Logger, References } from "effect"
+import { Log } from "@/util/log"
+
+export namespace EffectLogger {
+  type Fields = Record<string, unknown>
+
+  export interface Handle {
+    readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+    readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+    readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+    readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
+    readonly with: (extra: Fields) => Handle
+  }
+
+  const clean = (input?: Fields): Fields =>
+    Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
+
+  const text = (input: unknown): string => {
+    if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
+    return input === undefined ? "" : String(input)
+  }
+
+  const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
+    const ann = clean({ ...base, ...extra })
+    const fx = run(msg)
+    return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
+  }
+
+  export const logger = Logger.make((opts) => {
+    const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
+    const now = opts.date.getTime()
+    for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
+      extra[`logSpan.${key}`] = `${now - start}ms`
+    }
+    if (opts.cause.reasons.length > 0) {
+      extra.cause = Cause.pretty(opts.cause)
+    }
+
+    const svc = typeof extra.service === "string" ? extra.service : undefined
+    if (svc) delete extra.service
+    const log = svc ? Log.create({ service: svc }) : Log.Default
+    const msg = text(opts.message)
+
+    switch (opts.logLevel) {
+      case "Trace":
+      case "Debug":
+        return log.debug(msg, extra)
+      case "Warn":
+        return log.warn(msg, extra)
+      case "Error":
+      case "Fatal":
+        return log.error(msg, extra)
+      default:
+        return log.info(msg, extra)
+    }
+  })
+
+  export const layer = Logger.layer([logger], { mergeWithExisting: false })
+
+  export const create = (base: Fields = {}): Handle => ({
+    debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
+    info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
+    warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
+    error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
+    with: (extra) => create({ ...base, ...extra }),
+  })
+}

+ 32 - 25
packages/opencode/src/effect/oltp.ts

@@ -1,34 +1,41 @@
-import { Layer } from "effect"
+import { Duration, Layer } from "effect"
 import { FetchHttpClient } from "effect/unstable/http"
 import { Otlp } from "effect/unstable/observability"
+import { EffectLogger } from "@/effect/logger"
 import { Flag } from "@/flag/flag"
 import { CHANNEL, VERSION } from "@/installation/meta"
 
 export namespace Observability {
-  export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
+  const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
+  export const enabled = !!base
 
-  export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
-    ? Layer.empty
-    : Otlp.layerJson({
-        baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
-        loggerMergeWithExisting: false,
-        resource: {
-          serviceName: "opencode",
-          serviceVersion: VERSION,
-          attributes: {
-            "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
-            "opencode.client": Flag.OPENCODE_CLIENT,
-          },
+  const resource = {
+    serviceName: "opencode",
+    serviceVersion: VERSION,
+    attributes: {
+      "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
+      "opencode.client": Flag.OPENCODE_CLIENT,
+    },
+  }
+
+  const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
+    ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
+        (acc, x) => {
+          const [key, value] = x.split("=")
+          acc[key] = value
+          return acc
         },
-        headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
-          ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
-              (acc, x) => {
-                const [key, value] = x.split("=")
-                acc[key] = value
-                return acc
-              },
-              {} as Record<string, string>,
-            )
-          : undefined,
-      }).pipe(Layer.provide(FetchHttpClient.layer))
+        {} as Record<string, string>,
+      )
+    : undefined
+
+  export const layer = !base
+    ? EffectLogger.layer
+    : Otlp.layerJson({
+        baseUrl: base,
+        loggerExportInterval: Duration.seconds(1),
+        loggerMergeWithExisting: true,
+        resource,
+        headers,
+      }).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
 }

+ 9 - 7
packages/opencode/src/effect/run-service.ts

@@ -1,23 +1,25 @@
 import { Effect, Layer, ManagedRuntime } from "effect"
-import * as ServiceMap from "effect/ServiceMap"
+import * as Context from "effect/Context"
 import { Instance } from "@/project/instance"
-import { Context } from "@/util/context"
-import { InstanceRef } from "./instance-ref"
+import { LocalContext } from "@/util/local-context"
+import { InstanceRef, WorkspaceRef } from "./instance-ref"
 import { Observability } from "./oltp"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
 
 export const memoMap = Layer.makeMemoMapUnsafe()
 
-function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
+export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
   try {
     const ctx = Instance.current
-    return Effect.provideService(effect, InstanceRef, ctx)
+    const workspaceID = WorkspaceContext.workspaceID
+    return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
   } catch (err) {
-    if (!(err instanceof Context.NotFound)) throw err
+    if (!(err instanceof LocalContext.NotFound)) throw err
   }
   return effect
 }
 
-export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
+export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
   let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
   const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
 

+ 6 - 14
packages/opencode/src/effect/runner.ts

@@ -1,10 +1,10 @@
-import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
+import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
 
 export interface Runner<A, E = never> {
   readonly state: Runner.State<A, E>
   readonly busy: boolean
   readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
-  readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E>
+  readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
   readonly cancel: Effect.Effect<void>
 }
 
@@ -20,7 +20,6 @@ export namespace Runner {
   interface ShellHandle<A, E> {
     id: number
     fiber: Fiber.Fiber<A, E>
-    abort: AbortController
   }
 
   interface PendingHandle<A, E> {
@@ -100,13 +99,7 @@ export namespace Runner {
         }),
       ).pipe(Effect.flatten)
 
-    const stopShell = (shell: ShellHandle<A, E>) =>
-      Effect.gen(function* () {
-        shell.abort.abort()
-        const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
-        if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
-        yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
-      })
+    const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
 
     const ensureRunning = (work: Effect.Effect<A, E>) =>
       SynchronizedRef.modifyEffect(
@@ -138,7 +131,7 @@ export namespace Runner {
         ),
       )
 
-    const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) =>
+    const startShell = (work: Effect.Effect<A, E>) =>
       SynchronizedRef.modifyEffect(
         ref,
         Effect.fnUntraced(function* (st) {
@@ -153,9 +146,8 @@ export namespace Runner {
           }
           yield* busy
           const id = next()
-          const abort = new AbortController()
-          const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
-          const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
+          const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
+          const shell = { id, fiber } satisfies ShellHandle<A, E>
           return [
             Effect.gen(function* () {
               const exit = yield* Fiber.await(fiber)

+ 92 - 109
packages/opencode/src/file/index.ts

@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { Git } from "@/git"
-import { Effect, Layer, ServiceMap } from "effect"
+import { Effect, Layer, Context } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import fuzzysort from "fuzzysort"
 import ignore from "ignore"
@@ -11,7 +11,6 @@ import path from "path"
 import z from "zod"
 import { Global } from "../global"
 import { Instance } from "../project/instance"
-import { Filesystem } from "../util/filesystem"
 import { Log } from "../util/log"
 import { Protected } from "./protected"
 import { Ripgrep } from "./ripgrep"
@@ -338,12 +337,13 @@ export namespace File {
     }) => Effect.Effect<string[]>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
 
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
       const appFs = yield* AppFileSystem.Service
+      const git = yield* Git.Service
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("File.state")(() =>
@@ -410,6 +410,10 @@ export namespace File {
         cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
       })
 
+      const gitText = Effect.fnUntraced(function* (args: string[]) {
+        return (yield* git.run(args, { cwd: Instance.directory })).text()
+      })
+
       const init = Effect.fn("File.init")(function* () {
         yield* ensure()
       })
@@ -417,100 +421,87 @@ export namespace File {
       const status = Effect.fn("File.status")(function* () {
         if (Instance.project.vcs !== "git") return []
 
-        return yield* Effect.promise(async () => {
-          const diffOutput = (
-            await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
-              cwd: Instance.directory,
+        const diffOutput = yield* gitText([
+          "-c",
+          "core.fsmonitor=false",
+          "-c",
+          "core.quotepath=false",
+          "diff",
+          "--numstat",
+          "HEAD",
+        ])
+
+        const changed: File.Info[] = []
+
+        if (diffOutput.trim()) {
+          for (const line of diffOutput.trim().split("\n")) {
+            const [added, removed, file] = line.split("\t")
+            changed.push({
+              path: file,
+              added: added === "-" ? 0 : parseInt(added, 10),
+              removed: removed === "-" ? 0 : parseInt(removed, 10),
+              status: "modified",
             })
-          ).text()
-
-          const changed: File.Info[] = []
-
-          if (diffOutput.trim()) {
-            for (const line of diffOutput.trim().split("\n")) {
-              const [added, removed, file] = line.split("\t")
-              changed.push({
-                path: file,
-                added: added === "-" ? 0 : parseInt(added, 10),
-                removed: removed === "-" ? 0 : parseInt(removed, 10),
-                status: "modified",
-              })
-            }
           }
+        }
 
-          const untrackedOutput = (
-            await Git.run(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "ls-files",
-                "--others",
-                "--exclude-standard",
-              ],
-              {
-                cwd: Instance.directory,
-              },
-            )
-          ).text()
-
-          if (untrackedOutput.trim()) {
-            for (const file of untrackedOutput.trim().split("\n")) {
-              try {
-                const content = await Filesystem.readText(path.join(Instance.directory, file))
-                changed.push({
-                  path: file,
-                  added: content.split("\n").length,
-                  removed: 0,
-                  status: "added",
-                })
-              } catch {
-                continue
-              }
-            }
+        const untrackedOutput = yield* gitText([
+          "-c",
+          "core.fsmonitor=false",
+          "-c",
+          "core.quotepath=false",
+          "ls-files",
+          "--others",
+          "--exclude-standard",
+        ])
+
+        if (untrackedOutput.trim()) {
+          for (const file of untrackedOutput.trim().split("\n")) {
+            const content = yield* appFs
+              .readFileString(path.join(Instance.directory, file))
+              .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
+            if (content === undefined) continue
+            changed.push({
+              path: file,
+              added: content.split("\n").length,
+              removed: 0,
+              status: "added",
+            })
           }
+        }
 
-          const deletedOutput = (
-            await Git.run(
-              [
-                "-c",
-                "core.fsmonitor=false",
-                "-c",
-                "core.quotepath=false",
-                "diff",
-                "--name-only",
-                "--diff-filter=D",
-                "HEAD",
-              ],
-              {
-                cwd: Instance.directory,
-              },
-            )
-          ).text()
-
-          if (deletedOutput.trim()) {
-            for (const file of deletedOutput.trim().split("\n")) {
-              changed.push({
-                path: file,
-                added: 0,
-                removed: 0,
-                status: "deleted",
-              })
-            }
+        const deletedOutput = yield* gitText([
+          "-c",
+          "core.fsmonitor=false",
+          "-c",
+          "core.quotepath=false",
+          "diff",
+          "--name-only",
+          "--diff-filter=D",
+          "HEAD",
+        ])
+
+        if (deletedOutput.trim()) {
+          for (const file of deletedOutput.trim().split("\n")) {
+            changed.push({
+              path: file,
+              added: 0,
+              removed: 0,
+              status: "deleted",
+            })
           }
+        }
 
-          return changed.map((item) => {
-            const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
-            return {
-              ...item,
-              path: path.relative(Instance.directory, full),
-            }
-          })
+        return changed.map((item) => {
+          const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
+          return {
+            ...item,
+            path: path.relative(Instance.directory, full),
+          }
         })
       })
 
-      const read = Effect.fn("File.read")(function* (file: string) {
+      const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
         using _ = log.time("read", { file })
         const full = path.join(Instance.directory, file)
 
@@ -558,27 +549,19 @@ export namespace File {
         )
 
         if (Instance.project.vcs === "git") {
-          return yield* Effect.promise(async (): Promise<File.Content> => {
-            let diff = (
-              await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
-            ).text()
-            if (!diff.trim()) {
-              diff = (
-                await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
-                  cwd: Instance.directory,
-                })
-              ).text()
-            }
-            if (diff.trim()) {
-              const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
-              const patch = structuredPatch(file, file, original, content, "old", "new", {
-                context: Infinity,
-                ignoreWhitespace: true,
-              })
-              return { type: "text", content, patch, diff: formatPatch(patch) }
-            }
-            return { type: "text", content }
-          })
+          let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
+          if (!diff.trim()) {
+            diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
+          }
+          if (diff.trim()) {
+            const original = yield* git.show(Instance.directory, "HEAD", file)
+            const patch = structuredPatch(file, file, original, content, "old", "new", {
+              context: Infinity,
+              ignoreWhitespace: true,
+            })
+            return { type: "text" as const, content, patch, diff: formatPatch(patch) }
+          }
+          return { type: "text" as const, content }
         }
 
         return { type: "text" as const, content }
@@ -660,7 +643,7 @@ export namespace File {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
 
   const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 70 - 0
packages/opencode/src/file/ripgrep.ts

@@ -3,10 +3,17 @@ import path from "path"
 import { Global } from "../global"
 import fs from "fs/promises"
 import z from "zod"
+import { Effect, Layer, Context } from "effect"
+import * as Stream from "effect/Stream"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import type { PlatformError } from "effect/PlatformError"
 import { NamedError } from "@opencode-ai/util/error"
 import { lazy } from "../util/lazy"
 
 import { Filesystem } from "../util/filesystem"
+import { AppFileSystem } from "../filesystem"
 import { Process } from "../util/process"
 import { which } from "../util/which"
 import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
     input.signal?.throwIfAborted()
   }
 
+  export interface Interface {
+    readonly files: (input: {
+      cwd: string
+      glob?: string[]
+      hidden?: boolean
+      follow?: boolean
+      maxDepth?: number
+    }) => Stream.Stream<string, PlatformError>
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
+
+  export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner
+      const afs = yield* AppFileSystem.Service
+
+      const files = Effect.fn("Ripgrep.files")(function* (input: {
+        cwd: string
+        glob?: string[]
+        hidden?: boolean
+        follow?: boolean
+        maxDepth?: number
+      }) {
+        const rgPath = yield* Effect.promise(() => filepath())
+        const isDir = yield* afs.isDir(input.cwd)
+        if (!isDir) {
+          return yield* Effect.die(
+            Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
+              code: "ENOENT" as const,
+              errno: -2,
+              path: input.cwd,
+            }),
+          )
+        }
+
+        const args = [rgPath, "--files", "--glob=!.git/*"]
+        if (input.follow) args.push("--follow")
+        if (input.hidden !== false) args.push("--hidden")
+        if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
+        if (input.glob) {
+          for (const g of input.glob) {
+            args.push(`--glob=${g}`)
+          }
+        }
+
+        return spawner
+          .streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
+          .pipe(Stream.filter((line: string) => line.length > 0))
+      })
+
+      return Service.of({
+        files: (input) => Stream.unwrap(files(input)),
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+  )
+
   export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
     log.info("tree", input)
     const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))

+ 6 - 25
packages/opencode/src/file/time.ts

@@ -1,6 +1,5 @@
-import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
+import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
@@ -34,10 +33,10 @@ export namespace FileTime {
     readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
     readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
     readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
-    readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
+    readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
 
   export const layer = Layer.effect(
     Service,
@@ -46,7 +45,7 @@ export namespace FileTime {
       const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
 
       const stamp = Effect.fnUntraced(function* (file: string) {
-        const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
+        const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
         return {
           read: yield* DateTime.nowAsDate,
           mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
@@ -103,8 +102,8 @@ export namespace FileTime {
         )
       })
 
-      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
-        return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
+      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
+        return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
       })
 
       return Service.of({ read, get, assert, withLock })
@@ -112,22 +111,4 @@ export namespace FileTime {
   ).pipe(Layer.orDie)
 
   export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export function read(sessionID: SessionID, file: string) {
-    return runPromise((s) => s.read(sessionID, file))
-  }
-
-  export function get(sessionID: SessionID, file: string) {
-    return runPromise((s) => s.get(sessionID, file))
-  }
-
-  export async function assert(sessionID: SessionID, filepath: string) {
-    return runPromise((s) => s.assert(sessionID, filepath))
-  }
-
-  export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
-    return runPromise((s) => s.withLock(filepath, fn))
-  }
 }

+ 7 - 15
packages/opencode/src/file/watcher.ts

@@ -1,4 +1,4 @@
-import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
+import { Cause, Effect, Layer, Scope, Context } from "effect"
 // @ts-ignore
 import { createWrapper } from "@parcel/watcher/wrapper"
 import type ParcelWatcher from "@parcel/watcher"
@@ -8,7 +8,6 @@ import z from "zod"
 import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { Flag } from "@/flag/flag"
 import { Git } from "@/git"
 import { Instance } from "@/project/instance"
@@ -65,12 +64,13 @@ export namespace FileWatcher {
     readonly init: () => Effect.Effect<void>
   }
 
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
+  export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
 
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
       const config = yield* Config.Service
+      const git = yield* Git.Service
 
       const state = yield* InstanceState.make(
         Effect.fn("FileWatcher.state")(
@@ -131,11 +131,9 @@ export namespace FileWatcher {
             }
 
             if (Instance.project.vcs === "git") {
-              const result = yield* Effect.promise(() =>
-                Git.run(["rev-parse", "--git-dir"], {
-                  cwd: Instance.project.worktree,
-                }),
-              )
+              const result = yield* git.run(["rev-parse", "--git-dir"], {
+                cwd: Instance.project.worktree,
+              })
               const vcsDir =
                 result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
               if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -161,11 +159,5 @@ export namespace FileWatcher {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export function init() {
-    return runPromise((svc) => svc.init())
-  }
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
 }

Неке датотеке нису приказане због велике количине промена