Sfoglia il codice sorgente

Merge branch 'dev' into kit/effectify-tool-registry

Kit Langton 4 mesi fa
parent
commit
a6d6ebc0f8
100 ha cambiato i file con 4154 aggiunte e 2969 eliminazioni
  1. 21 5
      .github/workflows/test.yml
  2. 17 11
      bun.lock
  3. 4 4
      nix/hashes.json
  4. 3 1
      package.json
  5. 145 7
      packages/app/e2e/actions.ts
  6. 39 5
      packages/app/e2e/fixtures.ts
  7. 10 43
      packages/app/e2e/projects/projects-switch.spec.ts
  8. 24 47
      packages/app/e2e/projects/workspace-new-session.spec.ts
  9. 13 25
      packages/app/e2e/session/session-model-persistence.spec.ts
  10. 114 0
      packages/app/e2e/session/session-review.spec.ts
  11. 7 2
      packages/app/src/components/prompt-input.tsx
  12. 3 56
      packages/app/src/components/prompt-input/files.ts
  13. 89 0
      packages/app/src/constants/file-picker.ts
  14. 1 0
      packages/app/src/context/global-sync.tsx
  15. 10 0
      packages/app/src/context/global-sync/child-store.ts
  16. 1 1
      packages/app/src/context/platform.tsx
  17. 1 0
      packages/app/src/index.ts
  18. 9 1
      packages/app/src/pages/error.tsx
  19. 27 20
      packages/app/src/pages/layout.tsx
  20. 2 2
      packages/app/src/pages/layout/helpers.ts
  21. 2 5
      packages/app/src/pages/layout/sidebar-project.tsx
  22. 2 2
      packages/app/src/pages/layout/sidebar-workspace.tsx
  23. 0 12
      packages/app/src/pages/session/file-tabs.tsx
  24. 10 1
      packages/desktop-electron/src/main/ipc.ts
  25. 2 0
      packages/desktop-electron/src/preload/types.ts
  26. 4 0
      packages/desktop-electron/src/renderer/index.tsx
  27. 3 0
      packages/desktop/src/index.tsx
  28. 3 3
      packages/opencode/package.json
  29. 54 0
      packages/opencode/script/build-node.ts
  30. 26 6
      packages/opencode/src/account/effect.ts
  31. 8 15
      packages/opencode/src/account/index.ts
  32. 6 4
      packages/opencode/src/account/repo.ts
  33. 1 1
      packages/opencode/src/account/schema.ts
  34. 1 1
      packages/opencode/src/agent/agent.ts
  35. 1 1
      packages/opencode/src/auth/effect.ts
  36. 2 2
      packages/opencode/src/auth/index.ts
  37. 5 5
      packages/opencode/src/cli/cmd/account.ts
  38. 1 0
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  39. 2 2
      packages/opencode/src/cli/cmd/upgrade.ts
  40. 20 20
      packages/opencode/src/effect/instances.ts
  41. 8 6
      packages/opencode/src/effect/runtime.ts
  42. 17 672
      packages/opencode/src/file/index.ts
  43. 674 0
      packages/opencode/src/file/service.ts
  44. 93 0
      packages/opencode/src/file/time-service.ts
  45. 9 91
      packages/opencode/src/file/time.ts
  46. 1 1
      packages/opencode/src/file/watcher.ts
  47. 7 148
      packages/opencode/src/format/index.ts
  48. 152 0
      packages/opencode/src/format/service.ts
  49. 1 1
      packages/opencode/src/global/index.ts
  50. 292 240
      packages/opencode/src/installation/index.ts
  51. 4 7
      packages/opencode/src/mcp/index.ts
  52. 1 0
      packages/opencode/src/node.ts
  53. 15 0
      packages/opencode/src/permission/evaluate.ts
  54. 30 273
      packages/opencode/src/permission/index.ts
  55. 282 0
      packages/opencode/src/permission/service.ts
  56. 1 1
      packages/opencode/src/plugin/index.ts
  57. 1 1
      packages/opencode/src/project/vcs.ts
  58. 215 0
      packages/opencode/src/provider/auth-service.ts
  59. 18 209
      packages/opencode/src/provider/auth.ts
  60. 123 14
      packages/opencode/src/provider/provider.ts
  61. 25 169
      packages/opencode/src/question/index.ts
  62. 172 0
      packages/opencode/src/question/service.ts
  63. 1 1
      packages/opencode/src/server/routes/project.ts
  64. 3 0
      packages/opencode/src/server/routes/provider.ts
  65. 51 16
      packages/opencode/src/session/llm.ts
  66. 1 1
      packages/opencode/src/session/message-v2.ts
  67. 12 10
      packages/opencode/src/session/prompt.ts
  68. 0 0
      packages/opencode/src/session/prompt/codex.txt
  69. 14 6
      packages/opencode/src/session/summary.ts
  70. 3 7
      packages/opencode/src/session/system.ts
  71. 238 0
      packages/opencode/src/skill/service.ts
  72. 15 235
      packages/opencode/src/skill/skill.ts
  73. 18 323
      packages/opencode/src/snapshot/index.ts
  74. 320 0
      packages/opencode/src/snapshot/service.ts
  75. 83 53
      packages/opencode/src/tool/registry.ts
  76. 3 3
      packages/opencode/src/tool/truncate-effect.ts
  77. 1 1
      packages/opencode/src/tool/truncate.ts
  78. 1 1
      packages/opencode/src/util/process.ts
  79. 5 1
      packages/opencode/src/util/which.ts
  80. 7 9
      packages/opencode/test/account/service.test.ts
  81. 128 0
      packages/opencode/test/effect/runtime.test.ts
  82. 12 6
      packages/opencode/test/file/watcher.test.ts
  83. 1 1
      packages/opencode/test/fixture/instance.ts
  84. 139 35
      packages/opencode/test/installation/installation.test.ts
  85. 121 2
      packages/opencode/test/provider/gitlab-duo.test.ts
  86. 1 1
      packages/opencode/test/tool/fixtures/models-api.json
  87. 13 3
      packages/opencode/test/tool/truncation.test.ts
  88. 4 8
      packages/web/src/content/docs/ar/providers.mdx
  89. 4 8
      packages/web/src/content/docs/bs/providers.mdx
  90. 4 8
      packages/web/src/content/docs/da/providers.mdx
  91. 4 8
      packages/web/src/content/docs/de/providers.mdx
  92. 4 8
      packages/web/src/content/docs/es/providers.mdx
  93. 4 8
      packages/web/src/content/docs/fr/providers.mdx
  94. 4 8
      packages/web/src/content/docs/it/providers.mdx
  95. 4 8
      packages/web/src/content/docs/ja/providers.mdx
  96. 4 8
      packages/web/src/content/docs/ko/providers.mdx
  97. 4 8
      packages/web/src/content/docs/nb/providers.mdx
  98. 4 8
      packages/web/src/content/docs/pl/providers.mdx
  99. 81 15
      packages/web/src/content/docs/providers.mdx
  100. 4 8
      packages/web/src/content/docs/pt-br/providers.mdx

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

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

+ 17 - 11
bun.lock

@@ -325,8 +325,6 @@
         "@aws-sdk/credential-providers": "3.993.0",
         "@clack/prompts": "1.0.0-alpha.1",
         "@effect/platform-node": "catalog:",
-        "@gitlab/gitlab-ai-provider": "3.6.0",
-        "@gitlab/opencode-gitlab-auth": "1.3.3",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
         "@modelcontextprotocol/sdk": "1.25.2",
@@ -358,6 +356,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
+        "gitlab-ai-provider": "5.2.2",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -368,6 +367,7 @@
         "mime-types": "3.0.2",
         "minimatch": "10.0.3",
         "open": "10.1.2",
+        "opencode-gitlab-auth": "2.0.0",
         "opentui-spinner": "0.0.6",
         "partial-json": "0.1.7",
         "remeda": "catalog:",
@@ -586,6 +586,8 @@
   ],
   "patchedDependencies": {
     "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
+    "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
+    "@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
   },
   "overrides": {
@@ -1108,10 +1110,6 @@
 
     "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
 
-    "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
-
-    "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "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/core@3.2.0", "", { "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=="],
 
     "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
@@ -3030,6 +3028,8 @@
 
     "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
+    "gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="],
+
     "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 
     "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3782,6 +3782,8 @@
 
     "opencode": ["opencode@workspace:packages/opencode"],
 
+    "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
+
     "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
 
     "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -4244,7 +4246,7 @@
 
     "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
 
-    "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
+    "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
 
     "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
 
@@ -5058,10 +5060,6 @@
 
     "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
 
-    "@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
-
-    "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
     "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "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=="],
 
     "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -5458,6 +5456,10 @@
 
     "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
 
+    "gitlab-ai-provider/openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="],
+
+    "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
     "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
 
     "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -5534,6 +5536,8 @@
 
     "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
 
+    "opencode-gitlab-auth/open": ["open@10.2.0", "", { "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=="],
+
     "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "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/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6284,6 +6288,8 @@
 
     "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
 
+    "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
+
     "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
 
     "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
-    "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
-    "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
-    "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
+    "x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
+    "aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
+    "aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
+    "x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
   }
 }

+ 3 - 1
package.json

@@ -112,6 +112,8 @@
   },
   "patchedDependencies": {
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
-    "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
+    "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
+    "@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
+    "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
   }
 }

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

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

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

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

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

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

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

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

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

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

+ 114 - 0
packages/app/e2e/session/session-review.spec.ts

@@ -169,6 +169,70 @@ async function overflow(page: Parameters<typeof test>[0]["page"], file: string)
   }
 }
 
+async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
+  const row = page.locator(`[data-file="${file}"]`).first()
+  await expect(row).toBeVisible()
+  await row.hover()
+
+  const open = row.getByRole("button", { name: /^Open file$/i }).first()
+  await expect(open).toBeVisible()
+  await open.click()
+
+  const tab = page.getByRole("tab", { name: file }).first()
+  await expect(tab).toBeVisible()
+  await tab.click()
+
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  await expect(viewer).toBeVisible()
+  return viewer
+}
+
+async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  await expect(viewer).toBeVisible()
+
+  const line = viewer.locator('diffs-container [data-line="2"]').first()
+  await expect(line).toBeVisible()
+  await line.hover()
+
+  const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
+  await expect(add).toBeVisible()
+  await add.click()
+
+  const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
+  await expect(area).toBeVisible()
+  await area.fill(note)
+
+  const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
+  await expect(submit).toBeEnabled()
+  await submit.click()
+
+  await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
+  await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
+}
+
+async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
+  const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+  const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
+  const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
+  const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
+
+  const [width, viewBox, popBox, toolsBox] = await Promise.all([
+    view.evaluate((el) => el.scrollWidth - el.clientWidth),
+    view.boundingBox(),
+    pop.boundingBox(),
+    tools.boundingBox(),
+  ])
+
+  if (!viewBox || !popBox || !toolsBox) return null
+
+  return {
+    width,
+    pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
+    tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
+  }
+}
+
 test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
   test.setTimeout(180_000)
 
@@ -218,6 +282,56 @@ test("review applies inline comment clicks without horizontal overflow", async (
   })
 })
 
+test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
+  test.setTimeout(180_000)
+
+  const tag = `review-file-comment-${Date.now()}`
+  const file = `review-file-comment-${tag}.txt`
+  const note = `comment ${tag}`
+
+  await page.setViewportSize({ width: 1280, height: 900 })
+
+  await withProject(async (project) => {
+    const sdk = createSdk(project.directory)
+
+    await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
+      await patch(sdk, session.id, seed([{ file, mark: tag }]))
+
+      await expect
+        .poll(
+          async () => {
+            const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+            return diff.length
+          },
+          { timeout: 60_000 },
+        )
+        .toBe(1)
+
+      await project.gotoSession(session.id)
+      await show(page)
+
+      const tab = page.getByRole("tab", { name: /Review/i }).first()
+      await expect(tab).toBeVisible()
+      await tab.click()
+
+      await expand(page)
+      await waitMark(page, file, tag)
+      await openReviewFile(page, file)
+      await fileComment(page, note)
+
+      await expect
+        .poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+      await expect
+        .poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+      await expect
+        .poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+    })
+  })
+})
+
 test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
   test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
   test.setTimeout(180_000)

+ 7 - 2
packages/app/src/components/prompt-input.tsx

@@ -1383,11 +1383,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <input
               ref={fileInputRef}
               type="file"
+              multiple
               accept={ACCEPTED_FILE_TYPES.join(",")}
               class="hidden"
               onChange={(e) => {
-                const file = e.currentTarget.files?.[0]
-                if (file) void addAttachment(file)
+                const list = e.currentTarget.files
+                if (list) {
+                  for (const file of Array.from(list)) {
+                    void addAttachment(file)
+                  }
+                }
                 e.currentTarget.value = ""
               }}
             />

+ 3 - 56
packages/app/src/components/prompt-input/files.ts

@@ -1,4 +1,6 @@
-export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
+
+export { ACCEPTED_FILE_TYPES }
 
 const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
 const IMAGE_EXTS = new Map([
@@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([
   "application/yaml",
 ])
 
-export const ACCEPTED_FILE_TYPES = [
-  ...ACCEPTED_IMAGE_TYPES,
-  "application/pdf",
-  "text/*",
-  "application/json",
-  "application/ld+json",
-  "application/toml",
-  "application/x-toml",
-  "application/x-yaml",
-  "application/xml",
-  "application/yaml",
-  ".c",
-  ".cc",
-  ".cjs",
-  ".conf",
-  ".cpp",
-  ".css",
-  ".csv",
-  ".cts",
-  ".env",
-  ".go",
-  ".gql",
-  ".graphql",
-  ".h",
-  ".hh",
-  ".hpp",
-  ".htm",
-  ".html",
-  ".ini",
-  ".java",
-  ".js",
-  ".json",
-  ".jsx",
-  ".log",
-  ".md",
-  ".mdx",
-  ".mjs",
-  ".mts",
-  ".py",
-  ".rb",
-  ".rs",
-  ".sass",
-  ".scss",
-  ".sh",
-  ".sql",
-  ".toml",
-  ".ts",
-  ".tsx",
-  ".txt",
-  ".xml",
-  ".yaml",
-  ".yml",
-  ".zsh",
-]
-
 const SAMPLE = 4096
 
 function kind(type: string) {

+ 89 - 0
packages/app/src/constants/file-picker.ts

@@ -0,0 +1,89 @@
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+
+export const ACCEPTED_FILE_TYPES = [
+  ...ACCEPTED_IMAGE_TYPES,
+  "application/pdf",
+  "text/*",
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+  ".c",
+  ".cc",
+  ".cjs",
+  ".conf",
+  ".cpp",
+  ".css",
+  ".csv",
+  ".cts",
+  ".env",
+  ".go",
+  ".gql",
+  ".graphql",
+  ".h",
+  ".hh",
+  ".hpp",
+  ".htm",
+  ".html",
+  ".ini",
+  ".java",
+  ".js",
+  ".json",
+  ".jsx",
+  ".log",
+  ".md",
+  ".mdx",
+  ".mjs",
+  ".mts",
+  ".py",
+  ".rb",
+  ".rs",
+  ".sass",
+  ".scss",
+  ".sh",
+  ".sql",
+  ".toml",
+  ".ts",
+  ".tsx",
+  ".txt",
+  ".xml",
+  ".yaml",
+  ".yml",
+  ".zsh",
+]
+
+const MIME_EXT = new Map([
+  ["image/png", "png"],
+  ["image/jpeg", "jpg"],
+  ["image/gif", "gif"],
+  ["image/webp", "webp"],
+  ["application/pdf", "pdf"],
+  ["application/json", "json"],
+  ["application/ld+json", "jsonld"],
+  ["application/toml", "toml"],
+  ["application/x-toml", "toml"],
+  ["application/x-yaml", "yaml"],
+  ["application/xml", "xml"],
+  ["application/yaml", "yaml"],
+])
+
+const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
+
+export const ACCEPTED_FILE_EXTENSIONS = Array.from(
+  new Set(
+    ACCEPTED_FILE_TYPES.flatMap((item) => {
+      if (item.startsWith(".")) return [item.slice(1)]
+      if (item === "text/*") return TEXT_EXT
+      const out = MIME_EXT.get(item)
+      return out ? [out] : []
+    }),
+  ),
+).sort()
+
+export function filePickerFilters(ext?: string[]) {
+  if (!ext || ext.length === 0) return undefined
+  return [{ name: "Files", extensions: ext }]
+}

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

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

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

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

+ 1 - 1
packages/app/src/context/platform.tsx

@@ -5,7 +5,7 @@ import { ServerConnection } from "./server"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
-type OpenFilePickerOptions = { title?: string; multiple?: boolean }
+type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
 type SaveFilePickerOptions = { title?: string; defaultPath?: string }
 type UpdateInfo = { updateAvailable: boolean; version?: string }
 

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

@@ -1,4 +1,5 @@
 export { AppBaseProviders, AppInterface } from "./app"
+export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { ServerConnection } from "./context/server"

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

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

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

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

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

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

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

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

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

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

+ 0 - 12
packages/app/src/pages/session/file-tabs.tsx

@@ -217,17 +217,6 @@ export function FileTabContent(props: { tab: string }) {
         onDelete={controls.remove}
       />
     ),
-    onDraftPopoverFocusOut: (e: FocusEvent) => {
-      const current = e.currentTarget as HTMLDivElement
-      const target = e.relatedTarget
-      if (target instanceof Node && current.contains(target)) return
-
-      setTimeout(() => {
-        if (!document.activeElement || !current.contains(document.activeElement)) {
-          setNote("commenting", null)
-        }
-      }, 0)
-    },
   })
 
   createEffect(() => {
@@ -426,7 +415,6 @@ export function FileTabContent(props: { tab: string }) {
           commentsUi.onLineSelectionEnd(range)
         }}
         search={search}
-        overflow="scroll"
         class="select-text"
         media={{
           mode: "auto",

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

@@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme,
 import { getStore } from "./store"
 import { setTitlebar } from "./windows"
 
+const pickerFilters = (ext?: string[]) => {
+  if (!ext || ext.length === 0) return undefined
+  return [{ name: "Files", extensions: ext }]
+}
+
 type Deps = {
   killSidecar: () => void
   installCli: () => Promise<string>
@@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) {
 
   ipcMain.handle(
     "open-file-picker",
-    async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
+    async (
+      _event: IpcMainInvokeEvent,
+      opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
+    ) => {
       const result = await dialog.showOpenDialog({
         properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
         title: opts?.title ?? "Choose a file",
         defaultPath: opts?.defaultPath,
+        filters: pickerFilters(opts?.extensions),
       })
       if (result.canceled) return null
       return opts?.multiple ? result.filePaths : result.filePaths[0]

+ 2 - 0
packages/desktop-electron/src/preload/types.ts

@@ -50,6 +50,8 @@ export type ElectronAPI = {
     multiple?: boolean
     title?: string
     defaultPath?: string
+    accept?: string[]
+    extensions?: string[]
   }) => Promise<string | string[] | null>
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   openLink: (url: string) => void

+ 4 - 0
packages/desktop-electron/src/renderer/index.tsx

@@ -1,6 +1,8 @@
 // @refresh reload
 
 import {
+  ACCEPTED_FILE_EXTENSIONS,
+  ACCEPTED_FILE_TYPES,
   AppBaseProviders,
   AppInterface,
   handleNotificationClick,
@@ -111,6 +113,8 @@ const createPlatform = (): Platform => {
       const result = await window.api.openFilePicker({
         multiple: opts?.multiple ?? false,
         title: opts?.title ?? t("desktop.dialog.chooseFile"),
+        accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
+        extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
       })
       return handleWslPicker(result)
     },

+ 3 - 0
packages/desktop/src/index.tsx

@@ -1,6 +1,8 @@
 // @refresh reload
 
 import {
+  ACCEPTED_FILE_EXTENSIONS,
+  filePickerFilters,
   AppBaseProviders,
   AppInterface,
   handleNotificationClick,
@@ -98,6 +100,7 @@ const createPlatform = (): Platform => {
         directory: false,
         multiple: opts?.multiple ?? false,
         title: opts?.title ?? t("desktop.dialog.chooseFile"),
+        filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS),
       })
       return handleWslPicker(result)
     },

+ 3 - 3
packages/opencode/package.json

@@ -89,8 +89,9 @@
     "@ai-sdk/xai": "2.0.51",
     "@aws-sdk/credential-providers": "3.993.0",
     "@clack/prompts": "1.0.0-alpha.1",
-    "@gitlab/gitlab-ai-provider": "3.6.0",
-    "@gitlab/opencode-gitlab-auth": "1.3.3",
+    "gitlab-ai-provider": "5.2.2",
+    "opencode-gitlab-auth": "2.0.0",
+    "@effect/platform-node": "catalog:",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
     "@modelcontextprotocol/sdk": "1.25.2",
@@ -104,7 +105,6 @@
     "@openrouter/ai-sdk-provider": "1.5.4",
     "@opentui/core": "0.1.87",
     "@opentui/solid": "0.1.87",
-    "@effect/platform-node": "catalog:",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",

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

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

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

@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
 import {
   type AccountError,
   AccessToken,
-  Account,
   AccountID,
   DeviceCode,
+  Info,
   RefreshToken,
   AccountServiceError,
   Login,
@@ -24,10 +24,30 @@ import {
   UserCode,
 } from "./schema"
 
-export * from "./schema"
+export {
+  AccountID,
+  type AccountError,
+  AccountRepoError,
+  AccountServiceError,
+  AccessToken,
+  RefreshToken,
+  DeviceCode,
+  UserCode,
+  Info,
+  Org,
+  OrgID,
+  Login,
+  PollSuccess,
+  PollPending,
+  PollSlow,
+  PollExpired,
+  PollDenied,
+  PollError,
+  PollResult,
+} from "./schema"
 
 export type AccountOrgs = {
-  account: Account
+  account: Info
   orgs: readonly Org[]
 }
 
@@ -108,10 +128,10 @@ const mapAccountServiceError =
       ),
     )
 
-export namespace AccountEffect {
+export namespace Account {
   export interface Interface {
-    readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
-    readonly list: () => Effect.Effect<Account[], AccountError>
+    readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
+    readonly list: () => Effect.Effect<Info[], AccountError>
     readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
     readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
     readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>

+ 8 - 15
packages/opencode/src/account/index.ts

@@ -1,31 +1,24 @@
 import { Effect, Option } from "effect"
 
-import {
-  Account as AccountSchema,
-  type AccountError,
-  type AccessToken,
-  AccountID,
-  AccountEffect,
-  OrgID,
-} from "./effect"
+import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
 
 export { AccessToken, AccountID, OrgID } from "./effect"
 
 import { runtime } from "@/effect/runtime"
 
-function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runSync(AccountEffect.Service.use(f))
+function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runSync(S.Service.use(f))
 }
 
-function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runPromise(AccountEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runPromise(S.Service.use(f))
 }
 
 export namespace Account {
-  export const Account = AccountSchema
-  export type Account = AccountSchema
+  export const Info = Model
+  export type Info = Model
 
-  export function active(): Account | undefined {
+  export function active(): Info | undefined {
     return Option.getOrUndefined(runSync((service) => service.active()))
   }
 

+ 6 - 4
packages/opencode/src/account/repo.ts

@@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
 
 import { Database } from "@/storage/db"
 import { AccountStateTable, AccountTable } from "./account.sql"
-import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema"
+import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
 
 export type AccountRow = (typeof AccountTable)["$inferSelect"]
 
@@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1
 
 export namespace AccountRepo {
   export interface Service {
-    readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
-    readonly list: () => Effect.Effect<Account[], AccountRepoError>
+    readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
+    readonly list: () => Effect.Effect<Info[], AccountRepoError>
     readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
     readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
     readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
   static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
     AccountRepo,
     Effect.gen(function* () {
-      const decode = Schema.decodeUnknownSync(Account)
+      const decode = Schema.decodeUnknownSync(Info)
 
       const query = <A>(f: (db: DbClient) => A) =>
         Effect.try({
@@ -136,6 +136,8 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
             .onConflictDoUpdate({
               target: AccountTable.id,
               set: {
+                email: input.email,
+                url: input.url,
                 access_token: input.accessToken,
                 refresh_token: input.refreshToken,
                 token_expiry: input.expiry,

+ 1 - 1
packages/opencode/src/account/schema.ts

@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
 )
 export type UserCode = Schema.Schema.Type<typeof UserCode>
 
-export class Account extends Schema.Class<Account>("Account")({
+export class Info extends Schema.Class<Info>("Account")({
   id: AccountID,
   email: Schema.String,
   url: Schema.String,

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

@@ -322,11 +322,11 @@ export namespace Agent {
       }),
     } satisfies Parameters<typeof generateObject>[0]
 
+    // TODO: clean this up so provider specific logic doesnt bleed over
     if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
       const result = streamObject({
         ...params,
         providerOptions: ProviderTransform.providerOptions(model, {
-          instructions: SystemPrompt.instructions(),
           store: false,
         }),
         onError: () => {},

+ 1 - 1
packages/opencode/src/auth/effect.ts

@@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json")
 
 const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
 
-export namespace AuthEffect {
+export namespace Auth {
   export interface Interface {
     readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
     readonly all: () => Effect.Effect<Record<string, Info>, AuthError>

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

@@ -5,8 +5,8 @@ import * as S from "./effect"
 
 export { OAUTH_DUMMY_KEY } from "./effect"
 
-function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
-  return runtime.runPromise(S.AuthEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
+  return runtime.runPromise(S.Auth.Service.use(f))
 }
 
 export namespace Auth {

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

@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
 import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
 import { runtime } from "@/effect/runtime"
-import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
+import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
 import { type AccountError } from "@/account/schema"
 import * as Prompt from "../effect/prompt"
 import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
 ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
 
 const loginEffect = Effect.fn("login")(function* (url: string) {
-  const service = yield* AccountEffect.Service
+  const service = yield* Account.Service
 
   yield* Prompt.intro("Log in")
   const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
 })
 
 const logoutEffect = Effect.fn("logout")(function* (email?: string) {
-  const service = yield* AccountEffect.Service
+  const service = yield* Account.Service
   const accounts = yield* service.list()
   if (accounts.length === 0) return yield* println("Not logged in")
 
@@ -98,7 +98,7 @@ interface OrgChoice {
 }
 
 const switchEffect = Effect.fn("switch")(function* () {
-  const service = yield* AccountEffect.Service
+  const service = yield* Account.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
 })
 
 const orgsEffect = Effect.fn("orgs")(function* () {
-  const service = yield* AccountEffect.Service
+  const service = yield* Account.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("No accounts found")

+ 1 - 0
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1667,6 +1667,7 @@ function InlineTool(props: {
 
   const denied = createMemo(
     () =>
+      error()?.includes("QuestionRejectedError") ||
       error()?.includes("rejected permission") ||
       error()?.includes("specified a rule") ||
       error()?.includes("user dismissed"),

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

@@ -58,10 +58,10 @@ export const UpgradeCommand = {
       spinner.stop("Upgrade failed", 1)
       if (err instanceof Installation.UpgradeFailedError) {
         // necessary because choco only allows install/upgrade in elevated terminals
-        if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) {
+        if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) {
           prompts.log.error("Please run the terminal as Administrator and try again")
         } else {
-          prompts.log.error(err.data.stderr)
+          prompts.log.error(err.stderr)
         }
       } else if (err instanceof Error) prompts.log.error(err.message)
       prompts.outro("Done")

+ 20 - 20
packages/opencode/src/effect/instances.ts

@@ -1,15 +1,15 @@
 import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { File } from "@/file"
-import { FileTime } from "@/file/time"
+import { File } from "@/file/service"
+import { FileTime } from "@/file/time-service"
 import { FileWatcher } from "@/file/watcher"
-import { Format } from "@/format"
-import { PermissionNext } from "@/permission"
+import { Format } from "@/format/service"
+import { Permission } from "@/permission/service"
 import { Instance } from "@/project/instance"
 import { Vcs } from "@/project/vcs"
-import { ProviderAuth } from "@/provider/auth"
-import { Question } from "@/question"
-import { Skill } from "@/skill/skill"
-import { Snapshot } from "@/snapshot"
+import { ProviderAuth } from "@/provider/auth-service"
+import { Question } from "@/question/service"
+import { Skill } from "@/skill/service"
+import { Snapshot } from "@/snapshot/service"
 import { ToolRegistry } from "@/tool/registry"
 import { InstanceContext } from "./instance-context"
 import { registerDisposer } from "./instance-registry"
@@ -18,7 +18,7 @@ export { InstanceContext } from "./instance-context"
 
 export type InstanceServices =
   | Question.Service
-  | PermissionNext.Service
+  | Permission.Service
   | ProviderAuth.Service
   | FileWatcher.Service
   | Vcs.Service
@@ -38,17 +38,17 @@ export type InstanceServices =
 function lookup(_key: string) {
   const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
   return Layer.mergeAll(
-    Layer.fresh(Question.layer),
-    Layer.fresh(PermissionNext.layer),
-    Layer.fresh(ProviderAuth.defaultLayer),
-    Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
-    Layer.fresh(Vcs.layer),
-    Layer.fresh(FileTime.layer).pipe(Layer.orDie),
-    Layer.fresh(Format.layer),
-    Layer.fresh(File.layer),
-    Layer.fresh(Skill.defaultLayer),
-    Layer.fresh(Snapshot.defaultLayer),
-    Layer.fresh(ToolRegistry.layer),
+    Question.layer,
+    Permission.layer,
+    ProviderAuth.defaultLayer,
+    FileWatcher.layer,
+    Vcs.layer,
+    FileTime.layer,
+    Format.layer,
+    File.layer,
+    Skill.defaultLayer,
+    Snapshot.defaultLayer,
+    ToolRegistry.layer,
   ).pipe(Layer.provide(ctx))
 }
 

+ 8 - 6
packages/opencode/src/effect/runtime.ts

@@ -1,17 +1,19 @@
 import { Effect, Layer, ManagedRuntime } from "effect"
-import { AccountEffect } from "@/account/effect"
-import { AuthEffect } from "@/auth/effect"
+import { Account } from "@/account/effect"
+import { Auth } from "@/auth/effect"
 import { Instances } from "@/effect/instances"
 import type { InstanceServices } from "@/effect/instances"
-import { TruncateEffect } from "@/tool/truncate-effect"
+import { Installation } from "@/installation"
+import { Truncate } from "@/tool/truncate-effect"
 import { Instance } from "@/project/instance"
 
 export const runtime = ManagedRuntime.make(
   Layer.mergeAll(
-    AccountEffect.defaultLayer, //
-    TruncateEffect.defaultLayer,
+    Account.defaultLayer, //
+    Installation.defaultLayer,
+    Truncate.defaultLayer,
     Instances.layer,
-  ).pipe(Layer.provideMerge(AuthEffect.layer)),
+  ).pipe(Layer.provideMerge(Auth.layer)),
 )
 
 export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

+ 17 - 672
packages/opencode/src/file/index.ts

@@ -1,695 +1,40 @@
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
 import { runPromiseInstance } from "@/effect/runtime"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
-import { formatPatch, structuredPatch } from "diff"
-import fs from "fs"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-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"
+import { File as S } from "./service"
 
 export namespace File {
-  export const Info = z
-    .object({
-      path: z.string(),
-      added: z.number().int(),
-      removed: z.number().int(),
-      status: z.enum(["added", "deleted", "modified"]),
-    })
-    .meta({
-      ref: "File",
-    })
+  export const Info = S.Info
+  export type Info = S.Info
 
-  export type Info = z.infer<typeof Info>
+  export const Node = S.Node
+  export type Node = S.Node
 
-  export const Node = z
-    .object({
-      name: z.string(),
-      path: z.string(),
-      absolute: z.string(),
-      type: z.enum(["file", "directory"]),
-      ignored: z.boolean(),
-    })
-    .meta({
-      ref: "FileNode",
-    })
-  export type Node = z.infer<typeof Node>
+  export const Content = S.Content
+  export type Content = S.Content
 
-  export const Content = z
-    .object({
-      type: z.enum(["text", "binary"]),
-      content: z.string(),
-      diff: z.string().optional(),
-      patch: z
-        .object({
-          oldFileName: z.string(),
-          newFileName: z.string(),
-          oldHeader: z.string().optional(),
-          newHeader: z.string().optional(),
-          hunks: z.array(
-            z.object({
-              oldStart: z.number(),
-              oldLines: z.number(),
-              newStart: z.number(),
-              newLines: z.number(),
-              lines: z.array(z.string()),
-            }),
-          ),
-          index: z.string().optional(),
-        })
-        .optional(),
-      encoding: z.literal("base64").optional(),
-      mimeType: z.string().optional(),
-    })
-    .meta({
-      ref: "FileContent",
-    })
-  export type Content = z.infer<typeof Content>
+  export const Event = S.Event
 
-  export const Event = {
-    Edited: BusEvent.define(
-      "file.edited",
-      z.object({
-        file: z.string(),
-      }),
-    ),
-  }
+  export type Interface = S.Interface
+
+  export const Service = S.Service
+  export const layer = S.layer
 
   export function init() {
-    return runPromiseInstance(Service.use((svc) => svc.init()))
+    return runPromiseInstance(S.Service.use((svc) => svc.init()))
   }
 
   export async function status() {
-    return runPromiseInstance(Service.use((svc) => svc.status()))
+    return runPromiseInstance(S.Service.use((svc) => svc.status()))
   }
 
   export async function read(file: string): Promise<Content> {
-    return runPromiseInstance(Service.use((svc) => svc.read(file)))
+    return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
   }
 
   export async function list(dir?: string) {
-    return runPromiseInstance(Service.use((svc) => svc.list(dir)))
+    return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
   }
 
   export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
-    return runPromiseInstance(Service.use((svc) => svc.search(input)))
-  }
-
-  const log = Log.create({ service: "file" })
-
-  const binary = new Set([
-    "exe",
-    "dll",
-    "pdb",
-    "bin",
-    "so",
-    "dylib",
-    "o",
-    "a",
-    "lib",
-    "wav",
-    "mp3",
-    "ogg",
-    "oga",
-    "ogv",
-    "ogx",
-    "flac",
-    "aac",
-    "wma",
-    "m4a",
-    "weba",
-    "mp4",
-    "avi",
-    "mov",
-    "wmv",
-    "flv",
-    "webm",
-    "mkv",
-    "zip",
-    "tar",
-    "gz",
-    "gzip",
-    "bz",
-    "bz2",
-    "bzip",
-    "bzip2",
-    "7z",
-    "rar",
-    "xz",
-    "lz",
-    "z",
-    "pdf",
-    "doc",
-    "docx",
-    "ppt",
-    "pptx",
-    "xls",
-    "xlsx",
-    "dmg",
-    "iso",
-    "img",
-    "vmdk",
-    "ttf",
-    "otf",
-    "woff",
-    "woff2",
-    "eot",
-    "sqlite",
-    "db",
-    "mdb",
-    "apk",
-    "ipa",
-    "aab",
-    "xapk",
-    "app",
-    "pkg",
-    "deb",
-    "rpm",
-    "snap",
-    "flatpak",
-    "appimage",
-    "msi",
-    "msp",
-    "jar",
-    "war",
-    "ear",
-    "class",
-    "kotlin_module",
-    "dex",
-    "vdex",
-    "odex",
-    "oat",
-    "art",
-    "wasm",
-    "wat",
-    "bc",
-    "ll",
-    "s",
-    "ko",
-    "sys",
-    "drv",
-    "efi",
-    "rom",
-    "com",
-    "cmd",
-    "ps1",
-    "sh",
-    "bash",
-    "zsh",
-    "fish",
-  ])
-
-  const image = new Set([
-    "png",
-    "jpg",
-    "jpeg",
-    "gif",
-    "bmp",
-    "webp",
-    "ico",
-    "tif",
-    "tiff",
-    "svg",
-    "svgz",
-    "avif",
-    "apng",
-    "jxl",
-    "heic",
-    "heif",
-    "raw",
-    "cr2",
-    "nef",
-    "arw",
-    "dng",
-    "orf",
-    "raf",
-    "pef",
-    "x3f",
-  ])
-
-  const text = new Set([
-    "ts",
-    "tsx",
-    "mts",
-    "cts",
-    "mtsx",
-    "ctsx",
-    "js",
-    "jsx",
-    "mjs",
-    "cjs",
-    "sh",
-    "bash",
-    "zsh",
-    "fish",
-    "ps1",
-    "psm1",
-    "cmd",
-    "bat",
-    "json",
-    "jsonc",
-    "json5",
-    "yaml",
-    "yml",
-    "toml",
-    "md",
-    "mdx",
-    "txt",
-    "xml",
-    "html",
-    "htm",
-    "css",
-    "scss",
-    "sass",
-    "less",
-    "graphql",
-    "gql",
-    "sql",
-    "ini",
-    "cfg",
-    "conf",
-    "env",
-  ])
-
-  const textName = new Set([
-    "dockerfile",
-    "makefile",
-    ".gitignore",
-    ".gitattributes",
-    ".editorconfig",
-    ".npmrc",
-    ".nvmrc",
-    ".prettierrc",
-    ".eslintrc",
-  ])
-
-  const mime: Record<string, string> = {
-    png: "image/png",
-    jpg: "image/jpeg",
-    jpeg: "image/jpeg",
-    gif: "image/gif",
-    bmp: "image/bmp",
-    webp: "image/webp",
-    ico: "image/x-icon",
-    tif: "image/tiff",
-    tiff: "image/tiff",
-    svg: "image/svg+xml",
-    svgz: "image/svg+xml",
-    avif: "image/avif",
-    apng: "image/apng",
-    jxl: "image/jxl",
-    heic: "image/heic",
-    heif: "image/heif",
-  }
-
-  type Entry = { files: string[]; dirs: string[] }
-
-  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
-  const name = (file: string) => path.basename(file).toLowerCase()
-  const isImageByExtension = (file: string) => image.has(ext(file))
-  const isTextByExtension = (file: string) => text.has(ext(file))
-  const isTextByName = (file: string) => textName.has(name(file))
-  const isBinaryByExtension = (file: string) => binary.has(ext(file))
-  const isImage = (mimeType: string) => mimeType.startsWith("image/")
-  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
-
-  function shouldEncode(mimeType: string) {
-    const type = mimeType.toLowerCase()
-    log.info("shouldEncode", { type })
-    if (!type) return false
-    if (type.startsWith("text/")) return false
-    if (type.includes("charset=")) return false
-    const top = type.split("/", 2)[0]
-    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
-  }
-
-  const hidden = (item: string) => {
-    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
-    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+    return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
   }
-
-  const sortHiddenLast = (items: string[], prefer: boolean) => {
-    if (prefer) return items
-    const visible: string[] = []
-    const hiddenItems: string[] = []
-    for (const item of items) {
-      if (hidden(item)) hiddenItems.push(item)
-      else visible.push(item)
-    }
-    return [...visible, ...hiddenItems]
-  }
-
-  export interface Interface {
-    readonly init: () => Effect.Effect<void>
-    readonly status: () => Effect.Effect<File.Info[]>
-    readonly read: (file: string) => Effect.Effect<File.Content>
-    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
-    readonly search: (input: {
-      query: string
-      limit?: number
-      dirs?: boolean
-      type?: "file" | "directory"
-    }) => Effect.Effect<string[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      let cache: Entry = { files: [], dirs: [] }
-      const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
-
-      const scan = Effect.fn("File.scan")(function* () {
-        if (instance.directory === path.parse(instance.directory).root) return
-        const next: Entry = { files: [], dirs: [] }
-
-        yield* Effect.promise(async () => {
-          if (isGlobalHome) {
-            const dirs = new Set<string>()
-            const protectedNames = Protected.names()
-            const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
-            const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
-            const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
-            const top = await fs.promises
-              .readdir(instance.directory, { withFileTypes: true })
-              .catch(() => [] as fs.Dirent[])
-
-            for (const entry of top) {
-              if (!entry.isDirectory()) continue
-              if (shouldIgnoreName(entry.name)) continue
-              dirs.add(entry.name + "/")
-
-              const base = path.join(instance.directory, entry.name)
-              const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
-              for (const child of children) {
-                if (!child.isDirectory()) continue
-                if (shouldIgnoreNested(child.name)) continue
-                dirs.add(entry.name + "/" + child.name + "/")
-              }
-            }
-
-            next.dirs = Array.from(dirs).toSorted()
-          } else {
-            const seen = new Set<string>()
-            for await (const file of Ripgrep.files({ cwd: instance.directory })) {
-              next.files.push(file)
-              let current = file
-              while (true) {
-                const dir = path.dirname(current)
-                if (dir === ".") break
-                if (dir === current) break
-                current = dir
-                if (seen.has(dir)) continue
-                seen.add(dir)
-                next.dirs.push(dir + "/")
-              }
-            }
-          }
-        })
-
-        cache = next
-      })
-
-      const getFiles = () => cache
-
-      const scope = yield* Scope.Scope
-      let fiber: Fiber.Fiber<void> | undefined
-
-      const init = Effect.fn("File.init")(function* () {
-        if (!fiber) {
-          fiber = yield* scan().pipe(
-            Effect.catchCause(() => Effect.void),
-            Effect.forkIn(scope),
-          )
-        }
-        yield* Fiber.join(fiber)
-      })
-
-      const status = Effect.fn("File.status")(function* () {
-        if (instance.project.vcs !== "git") return []
-
-        return yield* Effect.promise(async () => {
-          const diffOutput = (
-            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
-              cwd: instance.directory,
-            })
-          ).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(
-              [
-                "-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 deletedOutput = (
-            await git(
-              [
-                "-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",
-              })
-            }
-          }
-
-          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) {
-        return yield* Effect.promise(async (): Promise<File.Content> => {
-          using _ = log.time("read", { file })
-          const full = path.join(instance.directory, file)
-
-          if (!Instance.containsPath(full)) {
-            throw new Error("Access denied: path escapes project directory")
-          }
-
-          if (isImageByExtension(file)) {
-            if (await Filesystem.exists(full)) {
-              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-              return {
-                type: "text",
-                content: buffer.toString("base64"),
-                mimeType: getImageMimeType(file),
-                encoding: "base64",
-              }
-            }
-            return { type: "text", content: "" }
-          }
-
-          const knownText = isTextByExtension(file) || isTextByName(file)
-
-          if (isBinaryByExtension(file) && !knownText) {
-            return { type: "binary", content: "" }
-          }
-
-          if (!(await Filesystem.exists(full))) {
-            return { type: "text", content: "" }
-          }
-
-          const mimeType = Filesystem.mimeType(full)
-          const encode = knownText ? false : shouldEncode(mimeType)
-
-          if (encode && !isImage(mimeType)) {
-            return { type: "binary", content: "", mimeType }
-          }
-
-          if (encode) {
-            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
-            return {
-              type: "text",
-              content: buffer.toString("base64"),
-              mimeType,
-              encoding: "base64",
-            }
-          }
-
-          const content = (await Filesystem.readText(full).catch(() => "")).trim()
-
-          if (instance.project.vcs === "git") {
-            let diff = (
-              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
-            ).text()
-            if (!diff.trim()) {
-              diff = (
-                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
-                  cwd: instance.directory,
-                })
-              ).text()
-            }
-            if (diff.trim()) {
-              const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
-              const patch = structuredPatch(file, file, original, content, "old", "new", {
-                context: Infinity,
-                ignoreWhitespace: true,
-              })
-              return {
-                type: "text",
-                content,
-                patch,
-                diff: formatPatch(patch),
-              }
-            }
-          }
-
-          return { type: "text", content }
-        })
-      })
-
-      const list = Effect.fn("File.list")(function* (dir?: string) {
-        return yield* Effect.promise(async () => {
-          const exclude = [".git", ".DS_Store"]
-          let ignored = (_: string) => false
-          if (instance.project.vcs === "git") {
-            const ig = ignore()
-            const gitignore = path.join(instance.project.worktree, ".gitignore")
-            if (await Filesystem.exists(gitignore)) {
-              ig.add(await Filesystem.readText(gitignore))
-            }
-            const ignoreFile = path.join(instance.project.worktree, ".ignore")
-            if (await Filesystem.exists(ignoreFile)) {
-              ig.add(await Filesystem.readText(ignoreFile))
-            }
-            ignored = ig.ignores.bind(ig)
-          }
-
-          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
-          if (!Instance.containsPath(resolved)) {
-            throw new Error("Access denied: path escapes project directory")
-          }
-
-          const nodes: File.Node[] = []
-          for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
-            if (exclude.includes(entry.name)) continue
-            const absolute = path.join(resolved, entry.name)
-            const file = path.relative(instance.directory, absolute)
-            const type = entry.isDirectory() ? "directory" : "file"
-            nodes.push({
-              name: entry.name,
-              path: file,
-              absolute,
-              type,
-              ignored: ignored(type === "directory" ? file + "/" : file),
-            })
-          }
-
-          return nodes.sort((a, b) => {
-            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
-            return a.name.localeCompare(b.name)
-          })
-        })
-      })
-
-      const search = Effect.fn("File.search")(function* (input: {
-        query: string
-        limit?: number
-        dirs?: boolean
-        type?: "file" | "directory"
-      }) {
-        return yield* Effect.promise(async () => {
-          const query = input.query.trim()
-          const limit = input.limit ?? 100
-          const kind = input.type ?? (input.dirs === false ? "file" : "all")
-          log.info("search", { query, kind })
-
-          const result = getFiles()
-          const preferHidden = query.startsWith(".") || query.includes("/.")
-
-          if (!query) {
-            if (kind === "file") return result.files.slice(0, limit)
-            return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
-          }
-
-          const items =
-            kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
-
-          const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
-          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
-          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
-          log.info("search", { query, kind, results: output.length })
-          return output
-        })
-      })
-
-      log.info("init")
-      return Service.of({ init, status, read, list, search })
-    }),
-  )
 }

+ 674 - 0
packages/opencode/src/file/service.ts

@@ -0,0 +1,674 @@
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceContext } from "@/effect/instance-context"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { formatPatch, structuredPatch } from "diff"
+import fs from "fs"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+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"
+
+export namespace File {
+  export const Info = z
+    .object({
+      path: z.string(),
+      added: z.number().int(),
+      removed: z.number().int(),
+      status: z.enum(["added", "deleted", "modified"]),
+    })
+    .meta({
+      ref: "File",
+    })
+
+  export type Info = z.infer<typeof Info>
+
+  export const Node = z
+    .object({
+      name: z.string(),
+      path: z.string(),
+      absolute: z.string(),
+      type: z.enum(["file", "directory"]),
+      ignored: z.boolean(),
+    })
+    .meta({
+      ref: "FileNode",
+    })
+  export type Node = z.infer<typeof Node>
+
+  export const Content = z
+    .object({
+      type: z.enum(["text", "binary"]),
+      content: z.string(),
+      diff: z.string().optional(),
+      patch: z
+        .object({
+          oldFileName: z.string(),
+          newFileName: z.string(),
+          oldHeader: z.string().optional(),
+          newHeader: z.string().optional(),
+          hunks: z.array(
+            z.object({
+              oldStart: z.number(),
+              oldLines: z.number(),
+              newStart: z.number(),
+              newLines: z.number(),
+              lines: z.array(z.string()),
+            }),
+          ),
+          index: z.string().optional(),
+        })
+        .optional(),
+      encoding: z.literal("base64").optional(),
+      mimeType: z.string().optional(),
+    })
+    .meta({
+      ref: "FileContent",
+    })
+  export type Content = z.infer<typeof Content>
+
+  export const Event = {
+    Edited: BusEvent.define(
+      "file.edited",
+      z.object({
+        file: z.string(),
+      }),
+    ),
+  }
+
+  const log = Log.create({ service: "file" })
+
+  const binary = new Set([
+    "exe",
+    "dll",
+    "pdb",
+    "bin",
+    "so",
+    "dylib",
+    "o",
+    "a",
+    "lib",
+    "wav",
+    "mp3",
+    "ogg",
+    "oga",
+    "ogv",
+    "ogx",
+    "flac",
+    "aac",
+    "wma",
+    "m4a",
+    "weba",
+    "mp4",
+    "avi",
+    "mov",
+    "wmv",
+    "flv",
+    "webm",
+    "mkv",
+    "zip",
+    "tar",
+    "gz",
+    "gzip",
+    "bz",
+    "bz2",
+    "bzip",
+    "bzip2",
+    "7z",
+    "rar",
+    "xz",
+    "lz",
+    "z",
+    "pdf",
+    "doc",
+    "docx",
+    "ppt",
+    "pptx",
+    "xls",
+    "xlsx",
+    "dmg",
+    "iso",
+    "img",
+    "vmdk",
+    "ttf",
+    "otf",
+    "woff",
+    "woff2",
+    "eot",
+    "sqlite",
+    "db",
+    "mdb",
+    "apk",
+    "ipa",
+    "aab",
+    "xapk",
+    "app",
+    "pkg",
+    "deb",
+    "rpm",
+    "snap",
+    "flatpak",
+    "appimage",
+    "msi",
+    "msp",
+    "jar",
+    "war",
+    "ear",
+    "class",
+    "kotlin_module",
+    "dex",
+    "vdex",
+    "odex",
+    "oat",
+    "art",
+    "wasm",
+    "wat",
+    "bc",
+    "ll",
+    "s",
+    "ko",
+    "sys",
+    "drv",
+    "efi",
+    "rom",
+    "com",
+    "cmd",
+    "ps1",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+  ])
+
+  const image = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "gif",
+    "bmp",
+    "webp",
+    "ico",
+    "tif",
+    "tiff",
+    "svg",
+    "svgz",
+    "avif",
+    "apng",
+    "jxl",
+    "heic",
+    "heif",
+    "raw",
+    "cr2",
+    "nef",
+    "arw",
+    "dng",
+    "orf",
+    "raf",
+    "pef",
+    "x3f",
+  ])
+
+  const text = new Set([
+    "ts",
+    "tsx",
+    "mts",
+    "cts",
+    "mtsx",
+    "ctsx",
+    "js",
+    "jsx",
+    "mjs",
+    "cjs",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+    "ps1",
+    "psm1",
+    "cmd",
+    "bat",
+    "json",
+    "jsonc",
+    "json5",
+    "yaml",
+    "yml",
+    "toml",
+    "md",
+    "mdx",
+    "txt",
+    "xml",
+    "html",
+    "htm",
+    "css",
+    "scss",
+    "sass",
+    "less",
+    "graphql",
+    "gql",
+    "sql",
+    "ini",
+    "cfg",
+    "conf",
+    "env",
+  ])
+
+  const textName = new Set([
+    "dockerfile",
+    "makefile",
+    ".gitignore",
+    ".gitattributes",
+    ".editorconfig",
+    ".npmrc",
+    ".nvmrc",
+    ".prettierrc",
+    ".eslintrc",
+  ])
+
+  const mime: Record<string, string> = {
+    png: "image/png",
+    jpg: "image/jpeg",
+    jpeg: "image/jpeg",
+    gif: "image/gif",
+    bmp: "image/bmp",
+    webp: "image/webp",
+    ico: "image/x-icon",
+    tif: "image/tiff",
+    tiff: "image/tiff",
+    svg: "image/svg+xml",
+    svgz: "image/svg+xml",
+    avif: "image/avif",
+    apng: "image/apng",
+    jxl: "image/jxl",
+    heic: "image/heic",
+    heif: "image/heif",
+  }
+
+  type Entry = { files: string[]; dirs: string[] }
+
+  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
+  const name = (file: string) => path.basename(file).toLowerCase()
+  const isImageByExtension = (file: string) => image.has(ext(file))
+  const isTextByExtension = (file: string) => text.has(ext(file))
+  const isTextByName = (file: string) => textName.has(name(file))
+  const isBinaryByExtension = (file: string) => binary.has(ext(file))
+  const isImage = (mimeType: string) => mimeType.startsWith("image/")
+  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
+
+  function shouldEncode(mimeType: string) {
+    const type = mimeType.toLowerCase()
+    log.info("shouldEncode", { type })
+    if (!type) return false
+    if (type.startsWith("text/")) return false
+    if (type.includes("charset=")) return false
+    const top = type.split("/", 2)[0]
+    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
+  }
+
+  const hidden = (item: string) => {
+    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+  }
+
+  const sortHiddenLast = (items: string[], prefer: boolean) => {
+    if (prefer) return items
+    const visible: string[] = []
+    const hiddenItems: string[] = []
+    for (const item of items) {
+      if (hidden(item)) hiddenItems.push(item)
+      else visible.push(item)
+    }
+    return [...visible, ...hiddenItems]
+  }
+
+  export interface Interface {
+    readonly init: () => Effect.Effect<void>
+    readonly status: () => Effect.Effect<File.Info[]>
+    readonly read: (file: string) => Effect.Effect<File.Content>
+    readonly list: (dir?: string) => Effect.Effect<File.Node[]>
+    readonly search: (input: {
+      query: string
+      limit?: number
+      dirs?: boolean
+      type?: "file" | "directory"
+    }) => Effect.Effect<string[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+      let cache: Entry = { files: [], dirs: [] }
+      const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
+
+      const scan = Effect.fn("File.scan")(function* () {
+        if (instance.directory === path.parse(instance.directory).root) return
+        const next: Entry = { files: [], dirs: [] }
+
+        yield* Effect.promise(async () => {
+          if (isGlobalHome) {
+            const dirs = new Set<string>()
+            const protectedNames = Protected.names()
+            const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+            const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
+            const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+            const top = await fs.promises
+              .readdir(instance.directory, { withFileTypes: true })
+              .catch(() => [] as fs.Dirent[])
+
+            for (const entry of top) {
+              if (!entry.isDirectory()) continue
+              if (shouldIgnoreName(entry.name)) continue
+              dirs.add(entry.name + "/")
+
+              const base = path.join(instance.directory, entry.name)
+              const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+              for (const child of children) {
+                if (!child.isDirectory()) continue
+                if (shouldIgnoreNested(child.name)) continue
+                dirs.add(entry.name + "/" + child.name + "/")
+              }
+            }
+
+            next.dirs = Array.from(dirs).toSorted()
+          } else {
+            const seen = new Set<string>()
+            for await (const file of Ripgrep.files({ cwd: instance.directory })) {
+              next.files.push(file)
+              let current = file
+              while (true) {
+                const dir = path.dirname(current)
+                if (dir === ".") break
+                if (dir === current) break
+                current = dir
+                if (seen.has(dir)) continue
+                seen.add(dir)
+                next.dirs.push(dir + "/")
+              }
+            }
+          }
+        })
+
+        cache = next
+      })
+
+      const getFiles = () => cache
+
+      const scope = yield* Scope.Scope
+      let fiber: Fiber.Fiber<void> | undefined
+
+      const init = Effect.fn("File.init")(function* () {
+        if (!fiber) {
+          fiber = yield* scan().pipe(
+            Effect.catchCause(() => Effect.void),
+            Effect.forkIn(scope),
+          )
+        }
+        yield* Fiber.join(fiber)
+      })
+
+      const status = Effect.fn("File.status")(function* () {
+        if (instance.project.vcs !== "git") return []
+
+        return yield* Effect.promise(async () => {
+          const diffOutput = (
+            await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+              cwd: instance.directory,
+            })
+          ).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(
+              [
+                "-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 deletedOutput = (
+            await git(
+              [
+                "-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",
+              })
+            }
+          }
+
+          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) {
+        return yield* Effect.promise(async (): Promise<File.Content> => {
+          using _ = log.time("read", { file })
+          const full = path.join(instance.directory, file)
+
+          if (!Instance.containsPath(full)) {
+            throw new Error("Access denied: path escapes project directory")
+          }
+
+          if (isImageByExtension(file)) {
+            if (await Filesystem.exists(full)) {
+              const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+              return {
+                type: "text",
+                content: buffer.toString("base64"),
+                mimeType: getImageMimeType(file),
+                encoding: "base64",
+              }
+            }
+            return { type: "text", content: "" }
+          }
+
+          const knownText = isTextByExtension(file) || isTextByName(file)
+
+          if (isBinaryByExtension(file) && !knownText) {
+            return { type: "binary", content: "" }
+          }
+
+          if (!(await Filesystem.exists(full))) {
+            return { type: "text", content: "" }
+          }
+
+          const mimeType = Filesystem.mimeType(full)
+          const encode = knownText ? false : shouldEncode(mimeType)
+
+          if (encode && !isImage(mimeType)) {
+            return { type: "binary", content: "", mimeType }
+          }
+
+          if (encode) {
+            const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+            return {
+              type: "text",
+              content: buffer.toString("base64"),
+              mimeType,
+              encoding: "base64",
+            }
+          }
+
+          const content = (await Filesystem.readText(full).catch(() => "")).trim()
+
+          if (instance.project.vcs === "git") {
+            let diff = (
+              await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
+            ).text()
+            if (!diff.trim()) {
+              diff = (
+                await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+                  cwd: instance.directory,
+                })
+              ).text()
+            }
+            if (diff.trim()) {
+              const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
+              const patch = structuredPatch(file, file, original, content, "old", "new", {
+                context: Infinity,
+                ignoreWhitespace: true,
+              })
+              return {
+                type: "text",
+                content,
+                patch,
+                diff: formatPatch(patch),
+              }
+            }
+          }
+
+          return { type: "text", content }
+        })
+      })
+
+      const list = Effect.fn("File.list")(function* (dir?: string) {
+        return yield* Effect.promise(async () => {
+          const exclude = [".git", ".DS_Store"]
+          let ignored = (_: string) => false
+          if (instance.project.vcs === "git") {
+            const ig = ignore()
+            const gitignore = path.join(instance.project.worktree, ".gitignore")
+            if (await Filesystem.exists(gitignore)) {
+              ig.add(await Filesystem.readText(gitignore))
+            }
+            const ignoreFile = path.join(instance.project.worktree, ".ignore")
+            if (await Filesystem.exists(ignoreFile)) {
+              ig.add(await Filesystem.readText(ignoreFile))
+            }
+            ignored = ig.ignores.bind(ig)
+          }
+
+          const resolved = dir ? path.join(instance.directory, dir) : instance.directory
+          if (!Instance.containsPath(resolved)) {
+            throw new Error("Access denied: path escapes project directory")
+          }
+
+          const nodes: File.Node[] = []
+          for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
+            if (exclude.includes(entry.name)) continue
+            const absolute = path.join(resolved, entry.name)
+            const file = path.relative(instance.directory, absolute)
+            const type = entry.isDirectory() ? "directory" : "file"
+            nodes.push({
+              name: entry.name,
+              path: file,
+              absolute,
+              type,
+              ignored: ignored(type === "directory" ? file + "/" : file),
+            })
+          }
+
+          return nodes.sort((a, b) => {
+            if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+            return a.name.localeCompare(b.name)
+          })
+        })
+      })
+
+      const search = Effect.fn("File.search")(function* (input: {
+        query: string
+        limit?: number
+        dirs?: boolean
+        type?: "file" | "directory"
+      }) {
+        return yield* Effect.promise(async () => {
+          const query = input.query.trim()
+          const limit = input.limit ?? 100
+          const kind = input.type ?? (input.dirs === false ? "file" : "all")
+          log.info("search", { query, kind })
+
+          const result = getFiles()
+          const preferHidden = query.startsWith(".") || query.includes("/.")
+
+          if (!query) {
+            if (kind === "file") return result.files.slice(0, limit)
+            return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
+          }
+
+          const items =
+            kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
+
+          const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+          const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
+          const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
+
+          log.info("search", { query, kind, results: output.length })
+          return output
+        })
+      })
+
+      log.info("init")
+      return Service.of({ init, status, read, list, search })
+    }),
+  ).pipe(Layer.fresh)
+}

+ 93 - 0
packages/opencode/src/file/time-service.ts

@@ -0,0 +1,93 @@
+import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
+import { Flag } from "@/flag/flag"
+import type { SessionID } from "@/session/schema"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
+
+export namespace FileTime {
+  const log = Log.create({ service: "file.time" })
+
+  export type Stamp = {
+    readonly read: Date
+    readonly mtime: number | undefined
+    readonly ctime: number | undefined
+    readonly size: number | undefined
+  }
+
+  const stamp = Effect.fnUntraced(function* (file: string) {
+    const stat = Filesystem.stat(file)
+    const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
+    return {
+      read: yield* DateTime.nowAsDate,
+      mtime: stat?.mtime?.getTime(),
+      ctime: stat?.ctime?.getTime(),
+      size,
+    }
+  })
+
+  const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
+    const value = reads.get(sessionID)
+    if (value) return value
+
+    const next = new Map<string, Stamp>()
+    reads.set(sessionID, next)
+    return next
+  }
+
+  export interface Interface {
+    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>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
+      const reads = new Map<SessionID, Map<string, Stamp>>()
+      const locks = new Map<string, Semaphore.Semaphore>()
+
+      const getLock = (filepath: string) => {
+        const lock = locks.get(filepath)
+        if (lock) return lock
+
+        const next = Semaphore.makeUnsafe(1)
+        locks.set(filepath, next)
+        return next
+      }
+
+      const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
+        log.info("read", { sessionID, file })
+        session(reads, sessionID).set(file, yield* stamp(file))
+      })
+
+      const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
+        return reads.get(sessionID)?.get(file)?.read
+      })
+
+      const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
+        if (disableCheck) return
+
+        const time = reads.get(sessionID)?.get(filepath)
+        if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
+
+        const next = yield* stamp(filepath)
+        const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
+        if (!changed) return
+
+        throw new Error(
+          `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
+        )
+      })
+
+      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
+        return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
+      })
+
+      return Service.of({ read, get, assert, withLock })
+    }),
+  ).pipe(Layer.orDie, Layer.fresh)
+}

+ 9 - 91
packages/opencode/src/file/time.ts

@@ -1,110 +1,28 @@
-import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
-import { Filesystem } from "../util/filesystem"
-import { Log } from "../util/log"
+import { FileTime as S } from "./time-service"
 
 export namespace FileTime {
-  const log = Log.create({ service: "file.time" })
+  export type Stamp = S.Stamp
 
-  export type Stamp = {
-    readonly read: Date
-    readonly mtime: number | undefined
-    readonly ctime: number | undefined
-    readonly size: number | undefined
-  }
-
-  const stamp = Effect.fnUntraced(function* (file: string) {
-    const stat = Filesystem.stat(file)
-    const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
-    return {
-      read: yield* DateTime.nowAsDate,
-      mtime: stat?.mtime?.getTime(),
-      ctime: stat?.ctime?.getTime(),
-      size,
-    }
-  })
-
-  const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
-    const value = reads.get(sessionID)
-    if (value) return value
-
-    const next = new Map<string, Stamp>()
-    reads.set(sessionID, next)
-    return next
-  }
-
-  export interface Interface {
-    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>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
-      const reads = new Map<SessionID, Map<string, Stamp>>()
-      const locks = new Map<string, Semaphore.Semaphore>()
-
-      const getLock = (filepath: string) => {
-        const lock = locks.get(filepath)
-        if (lock) return lock
-
-        const next = Semaphore.makeUnsafe(1)
-        locks.set(filepath, next)
-        return next
-      }
-
-      const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
-        log.info("read", { sessionID, file })
-        session(reads, sessionID).set(file, yield* stamp(file))
-      })
-
-      const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
-        return reads.get(sessionID)?.get(file)?.read
-      })
-
-      const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
-        if (disableCheck) return
-
-        const time = reads.get(sessionID)?.get(filepath)
-        if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
-
-        const next = yield* stamp(filepath)
-        const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
-        if (!changed) return
-
-        throw new Error(
-          `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
-        )
-      })
-
-      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
-        return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
-      })
+  export type Interface = S.Interface
 
-      return Service.of({ read, get, assert, withLock })
-    }),
-  )
+  export const Service = S.Service
+  export const layer = S.layer
 
   export function read(sessionID: SessionID, file: string) {
-    return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
+    return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
   }
 
   export function get(sessionID: SessionID, file: string) {
-    return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
+    return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
   }
 
   export async function assert(sessionID: SessionID, filepath: string) {
-    return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
+    return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
   }
 
   export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
-    return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
+    return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
   }
 }

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

@@ -137,5 +137,5 @@ export namespace FileWatcher {
         return Effect.succeed(Service.of({}))
       }),
     ),
-  )
+  ).pipe(Layer.orDie, Layer.fresh)
 }

+ 7 - 148
packages/opencode/src/format/index.ts

@@ -1,157 +1,16 @@
-import { Effect, Layer, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-import { InstanceContext } from "@/effect/instance-context"
-import path from "path"
-import { mergeDeep } from "remeda"
-import z from "zod"
-import { Bus } from "../bus"
-import { Config } from "../config/config"
-import { File } from "../file"
-import { Instance } from "../project/instance"
-import { Process } from "../util/process"
-import { Log } from "../util/log"
-import * as Formatter from "./formatter"
+import { Format as S } from "./service"
 
 export namespace Format {
-  const log = Log.create({ service: "format" })
+  export const Status = S.Status
+  export type Status = S.Status
 
-  export const Status = z
-    .object({
-      name: z.string(),
-      extensions: z.string().array(),
-      enabled: z.boolean(),
-    })
-    .meta({
-      ref: "FormatterStatus",
-    })
-  export type Status = z.infer<typeof Status>
+  export type Interface = S.Interface
 
-  export interface Interface {
-    readonly status: () => Effect.Effect<Status[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-
-      const enabled: Record<string, boolean> = {}
-      const formatters: Record<string, Formatter.Info> = {}
-
-      const cfg = yield* Effect.promise(() => Config.get())
-
-      if (cfg.formatter !== false) {
-        for (const item of Object.values(Formatter)) {
-          formatters[item.name] = item
-        }
-        for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
-          if (item.disabled) {
-            delete formatters[name]
-            continue
-          }
-          const info = mergeDeep(formatters[name] ?? {}, {
-            command: [],
-            extensions: [],
-            ...item,
-          })
-
-          if (info.command.length === 0) continue
-
-          formatters[name] = {
-            ...info,
-            name,
-            enabled: async () => true,
-          }
-        }
-      } else {
-        log.info("all formatters are disabled")
-      }
-
-      async function isEnabled(item: Formatter.Info) {
-        let status = enabled[item.name]
-        if (status === undefined) {
-          status = await item.enabled()
-          enabled[item.name] = status
-        }
-        return status
-      }
-
-      async function getFormatter(ext: string) {
-        const result = []
-        for (const item of Object.values(formatters)) {
-          log.info("checking", { name: item.name, ext })
-          if (!item.extensions.includes(ext)) continue
-          if (!(await isEnabled(item))) continue
-          log.info("enabled", { name: item.name, ext })
-          result.push(item)
-        }
-        return result
-      }
-
-      yield* Effect.acquireRelease(
-        Effect.sync(() =>
-          Bus.subscribe(
-            File.Event.Edited,
-            Instance.bind(async (payload) => {
-              const file = payload.properties.file
-              log.info("formatting", { file })
-              const ext = path.extname(file)
-
-              for (const item of await getFormatter(ext)) {
-                log.info("running", { command: item.command })
-                try {
-                  const proc = Process.spawn(
-                    item.command.map((x) => x.replace("$FILE", file)),
-                    {
-                      cwd: instance.directory,
-                      env: { ...process.env, ...item.environment },
-                      stdout: "ignore",
-                      stderr: "ignore",
-                    },
-                  )
-                  const exit = await proc.exited
-                  if (exit !== 0) {
-                    log.error("failed", {
-                      command: item.command,
-                      ...item.environment,
-                    })
-                  }
-                } catch (error) {
-                  log.error("failed to format file", {
-                    error,
-                    command: item.command,
-                    ...item.environment,
-                    file,
-                  })
-                }
-              }
-            }),
-          ),
-        ),
-        (unsubscribe) => Effect.sync(unsubscribe),
-      )
-      log.info("init")
-
-      const status = Effect.fn("Format.status")(function* () {
-        const result: Status[] = []
-        for (const formatter of Object.values(formatters)) {
-          const isOn = yield* Effect.promise(() => isEnabled(formatter))
-          result.push({
-            name: formatter.name,
-            extensions: formatter.extensions,
-            enabled: isOn,
-          })
-        }
-        return result
-      })
-
-      return Service.of({ status })
-    }),
-  )
+  export const Service = S.Service
+  export const layer = S.layer
 
   export async function status() {
-    return runPromiseInstance(Service.use((s) => s.status()))
+    return runPromiseInstance(S.Service.use((s) => s.status()))
   }
 }

+ 152 - 0
packages/opencode/src/format/service.ts

@@ -0,0 +1,152 @@
+import { Effect, Layer, ServiceMap } from "effect"
+import { InstanceContext } from "@/effect/instance-context"
+import path from "path"
+import { mergeDeep } from "remeda"
+import z from "zod"
+import { Bus } from "../bus"
+import { Config } from "../config/config"
+import { File } from "../file/service"
+import { Instance } from "../project/instance"
+import { Process } from "../util/process"
+import { Log } from "../util/log"
+import * as Formatter from "./formatter"
+
+export namespace Format {
+  const log = Log.create({ service: "format" })
+
+  export const Status = z
+    .object({
+      name: z.string(),
+      extensions: z.string().array(),
+      enabled: z.boolean(),
+    })
+    .meta({
+      ref: "FormatterStatus",
+    })
+  export type Status = z.infer<typeof Status>
+
+  export interface Interface {
+    readonly status: () => Effect.Effect<Status[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+
+      const enabled: Record<string, boolean> = {}
+      const formatters: Record<string, Formatter.Info> = {}
+
+      const cfg = yield* Effect.promise(() => Config.get())
+
+      if (cfg.formatter !== false) {
+        for (const item of Object.values(Formatter)) {
+          formatters[item.name] = item
+        }
+        for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
+          if (item.disabled) {
+            delete formatters[name]
+            continue
+          }
+          const info = mergeDeep(formatters[name] ?? {}, {
+            command: [],
+            extensions: [],
+            ...item,
+          })
+
+          if (info.command.length === 0) continue
+
+          formatters[name] = {
+            ...info,
+            name,
+            enabled: async () => true,
+          }
+        }
+      } else {
+        log.info("all formatters are disabled")
+      }
+
+      async function isEnabled(item: Formatter.Info) {
+        let status = enabled[item.name]
+        if (status === undefined) {
+          status = await item.enabled()
+          enabled[item.name] = status
+        }
+        return status
+      }
+
+      async function getFormatter(ext: string) {
+        const result = []
+        for (const item of Object.values(formatters)) {
+          log.info("checking", { name: item.name, ext })
+          if (!item.extensions.includes(ext)) continue
+          if (!(await isEnabled(item))) continue
+          log.info("enabled", { name: item.name, ext })
+          result.push(item)
+        }
+        return result
+      }
+
+      yield* Effect.acquireRelease(
+        Effect.sync(() =>
+          Bus.subscribe(
+            File.Event.Edited,
+            Instance.bind(async (payload) => {
+              const file = payload.properties.file
+              log.info("formatting", { file })
+              const ext = path.extname(file)
+
+              for (const item of await getFormatter(ext)) {
+                log.info("running", { command: item.command })
+                try {
+                  const proc = Process.spawn(
+                    item.command.map((x) => x.replace("$FILE", file)),
+                    {
+                      cwd: instance.directory,
+                      env: { ...process.env, ...item.environment },
+                      stdout: "ignore",
+                      stderr: "ignore",
+                    },
+                  )
+                  const exit = await proc.exited
+                  if (exit !== 0) {
+                    log.error("failed", {
+                      command: item.command,
+                      ...item.environment,
+                    })
+                  }
+                } catch (error) {
+                  log.error("failed to format file", {
+                    error,
+                    command: item.command,
+                    ...item.environment,
+                    file,
+                  })
+                }
+              }
+            }),
+          ),
+        ),
+        (unsubscribe) => Effect.sync(unsubscribe),
+      )
+      log.info("init")
+
+      const status = Effect.fn("Format.status")(function* () {
+        const result: Status[] = []
+        for (const formatter of Object.values(formatters)) {
+          const isOn = yield* Effect.promise(() => isEnabled(formatter))
+          result.push({
+            name: formatter.name,
+            extensions: formatter.extensions,
+            enabled: isOn,
+          })
+        }
+        return result
+      })
+
+      return Service.of({ status })
+    }),
+  ).pipe(Layer.fresh)
+}

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

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

+ 292 - 240
packages/opencode/src/installation/index.ts

@@ -1,12 +1,13 @@
-import { BusEvent } from "@/bus/bus-event"
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import path from "path"
 import z from "zod"
-import { NamedError } from "@opencode-ai/util/error"
-import { Log } from "../util/log"
-import { iife } from "@/util/iife"
+import { BusEvent } from "@/bus/bus-event"
 import { Flag } from "../flag/flag"
-import { Process } from "@/util/process"
-import { buffer } from "node:stream/consumers"
+import { Log } from "../util/log"
 
 declare global {
   const OPENCODE_VERSION: string
@@ -16,39 +17,7 @@ declare global {
 export namespace Installation {
   const log = Log.create({ service: "installation" })
 
-  async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
-    return Process.text(cmd, {
-      cwd: opts.cwd,
-      env: opts.env,
-      nothrow: true,
-    }).then((x) => x.text)
-  }
-
-  async function upgradeCurl(target: string) {
-    const body = await fetch("https://opencode.ai/install").then((res) => {
-      if (!res.ok) throw new Error(res.statusText)
-      return res.text()
-    })
-    const proc = Process.spawn(["bash"], {
-      stdin: "pipe",
-      stdout: "pipe",
-      stderr: "pipe",
-      env: {
-        ...process.env,
-        VERSION: target,
-      },
-    })
-    if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
-    proc.stdin.end(body)
-    const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
-    return {
-      code,
-      stdout,
-      stderr,
-    }
-  }
-
-  export type Method = Awaited<ReturnType<typeof method>>
+  export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
 
   export const Event = {
     Updated: BusEvent.define(
@@ -75,12 +44,9 @@ export namespace Installation {
     })
   export type Info = z.infer<typeof Info>
 
-  export async function info() {
-    return {
-      version: VERSION,
-      latest: await latest(),
-    }
-  }
+  export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
+  export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+  export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
 
   export function isPreview() {
     return CHANNEL !== "latest"
@@ -90,214 +56,300 @@ export namespace Installation {
     return CHANNEL === "local"
   }
 
-  export async function method() {
-    if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
-    if (process.execPath.includes(path.join(".local", "bin"))) return "curl"
-    const exec = process.execPath.toLowerCase()
-
-    const checks = [
-      {
-        name: "npm" as const,
-        command: () => text(["npm", "list", "-g", "--depth=0"]),
-      },
-      {
-        name: "yarn" as const,
-        command: () => text(["yarn", "global", "list"]),
-      },
-      {
-        name: "pnpm" as const,
-        command: () => text(["pnpm", "list", "-g", "--depth=0"]),
-      },
-      {
-        name: "bun" as const,
-        command: () => text(["bun", "pm", "ls", "-g"]),
-      },
-      {
-        name: "brew" as const,
-        command: () => text(["brew", "list", "--formula", "opencode"]),
-      },
-      {
-        name: "scoop" as const,
-        command: () => text(["scoop", "list", "opencode"]),
-      },
-      {
-        name: "choco" as const,
-        command: () => text(["choco", "list", "--limit-output", "opencode"]),
-      },
-    ]
-
-    checks.sort((a, b) => {
-      const aMatches = exec.includes(a.name)
-      const bMatches = exec.includes(b.name)
-      if (aMatches && !bMatches) return -1
-      if (!aMatches && bMatches) return 1
-      return 0
-    })
+  export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
+    stderr: Schema.String,
+  }) {}
 
-    for (const check of checks) {
-      const output = await check.command()
-      const installedName =
-        check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
-      if (output.includes(installedName)) {
-        return check.name
-      }
-    }
+  // Response schemas for external version APIs
+  const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
+  const NpmPackage = Schema.Struct({ version: Schema.String })
+  const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
+  const BrewInfoV2 = Schema.Struct({
+    formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
+  })
+  const ChocoPackage = Schema.Struct({
+    d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
+  })
+  const ScoopManifest = NpmPackage
 
-    return "unknown"
+  export interface Interface {
+    readonly info: () => Effect.Effect<Info>
+    readonly method: () => Effect.Effect<Method>
+    readonly latest: (method?: Method) => Effect.Effect<string>
+    readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
   }
 
-  export const UpgradeFailedError = NamedError.create(
-    "UpgradeFailedError",
-    z.object({
-      stderr: z.string(),
-    }),
-  )
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
 
-  async function getBrewFormula() {
-    const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
-    if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
-    const coreFormula = await text(["brew", "list", "--formula", "opencode"])
-    if (coreFormula.includes("opencode")) return "opencode"
-    return "opencode"
-  }
+  export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
+    Layer.effect(
+      Service,
+      Effect.gen(function* () {
+        const http = yield* HttpClient.HttpClient
+        const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
+        const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
 
-  export async function upgrade(method: Method, target: string) {
-    let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
-    switch (method) {
-      case "curl":
-        result = await upgradeCurl(target)
-        break
-      case "npm":
-        result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
-        break
-      case "pnpm":
-        result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
-        break
-      case "bun":
-        result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
-        break
-      case "brew": {
-        const formula = await getBrewFormula()
-        const env = {
-          HOMEBREW_NO_AUTO_UPDATE: "1",
-          ...process.env,
-        }
-        if (formula.includes("/")) {
-          const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
-          if (tap.code !== 0) {
-            result = tap
-            break
+        const text = Effect.fnUntraced(
+          function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+            const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
+              cwd: opts?.cwd,
+              env: opts?.env,
+              extendEnv: true,
+            })
+            const handle = yield* spawner.spawn(proc)
+            const out = yield* Stream.mkString(Stream.decodeText(handle.stdout))
+            yield* handle.exitCode
+            return out
+          },
+          Effect.scoped,
+          Effect.catch(() => Effect.succeed("")),
+        )
+
+        const run = Effect.fnUntraced(
+          function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+            const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
+              cwd: opts?.cwd,
+              env: opts?.env,
+              extendEnv: true,
+            })
+            const handle = yield* spawner.spawn(proc)
+            const [stdout, stderr] = yield* Effect.all(
+              [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+              { concurrency: 2 },
+            )
+            const code = yield* handle.exitCode
+            return { code, stdout, stderr }
+          },
+          Effect.scoped,
+          Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
+        )
+
+        const getBrewFormula = Effect.fnUntraced(function* () {
+          const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
+          if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
+          const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
+          if (coreFormula.includes("opencode")) return "opencode"
+          return "opencode"
+        })
+
+        const upgradeCurl = Effect.fnUntraced(
+          function* (target: string) {
+            const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
+            const body = yield* response.text
+            const bodyBytes = new TextEncoder().encode(body)
+            const proc = ChildProcess.make("bash", [], {
+              stdin: Stream.make(bodyBytes),
+              env: { VERSION: target },
+              extendEnv: true,
+            })
+            const handle = yield* spawner.spawn(proc)
+            const [stdout, stderr] = yield* Effect.all(
+              [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+              { concurrency: 2 },
+            )
+            const code = yield* handle.exitCode
+            return { code, stdout, stderr }
+          },
+          Effect.scoped,
+          Effect.orDie,
+        )
+
+        const methodImpl = Effect.fn("Installation.method")(function* () {
+          if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
+          if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
+          const exec = process.execPath.toLowerCase()
+
+          const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
+            { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
+            { name: "yarn", command: () => text(["yarn", "global", "list"]) },
+            { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
+            { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
+            { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
+            { name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
+            { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
+          ]
+
+          checks.sort((a, b) => {
+            const aMatches = exec.includes(a.name)
+            const bMatches = exec.includes(b.name)
+            if (aMatches && !bMatches) return -1
+            if (!aMatches && bMatches) return 1
+            return 0
+          })
+
+          for (const check of checks) {
+            const output = yield* check.command()
+            const installedName =
+              check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
+            if (output.includes(installedName)) {
+              return check.name
+            }
           }
-          const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
-          if (repo.code !== 0) {
-            result = repo
-            break
+
+          return "unknown" as Method
+        })
+
+        const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) {
+          const detectedMethod = installMethod || (yield* methodImpl())
+
+          if (detectedMethod === "brew") {
+            const formula = yield* getBrewFormula()
+            if (formula.includes("/")) {
+              const infoJson = yield* text(["brew", "info", "--json=v2", formula])
+              const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
+              return info.formulae[0].versions.stable
+            }
+            const response = yield* httpOk.execute(
+              HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
+                HttpClientRequest.acceptJson,
+              ),
+            )
+            const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
+            return data.versions.stable
+          }
+
+          if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
+            const r = (yield* text(["npm", "config", "get", "registry"])).trim()
+            const reg = r || "https://registry.npmjs.org"
+            const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
+            const channel = CHANNEL
+            const response = yield* httpOk.execute(
+              HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
+            )
+            const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
+            return data.version
           }
-          const dir = repo.text.trim()
-          if (dir) {
-            const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
-            if (pull.code !== 0) {
-              result = pull
+
+          if (detectedMethod === "choco") {
+            const response = yield* httpOk.execute(
+              HttpClientRequest.get(
+                "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
+              ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
+            )
+            const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
+            return data.d.results[0].Version
+          }
+
+          if (detectedMethod === "scoop") {
+            const response = yield* httpOk.execute(
+              HttpClientRequest.get(
+                "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
+              ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
+            )
+            const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
+            return data.version
+          }
+
+          const response = yield* httpOk.execute(
+            HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
+              HttpClientRequest.acceptJson,
+            ),
+          )
+          const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
+          return data.tag_name.replace(/^v/, "")
+        }, Effect.orDie)
+
+        const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
+          let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
+          switch (m) {
+            case "curl":
+              result = yield* upgradeCurl(target)
+              break
+            case "npm":
+              result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
+              break
+            case "pnpm":
+              result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
+              break
+            case "bun":
+              result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
+              break
+            case "brew": {
+              const formula = yield* getBrewFormula()
+              const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
+              if (formula.includes("/")) {
+                const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
+                if (tap.code !== 0) {
+                  result = tap
+                  break
+                }
+                const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
+                const dir = repo.trim()
+                if (dir) {
+                  const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
+                  if (pull.code !== 0) {
+                    result = pull
+                    break
+                  }
+                }
+              }
+              result = yield* run(["brew", "upgrade", formula], { env })
               break
             }
+            case "choco":
+              result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
+              break
+            case "scoop":
+              result = yield* run(["scoop", "install", `opencode@${target}`])
+              break
+            default:
+              throw new Error(`Unknown method: ${m}`)
           }
-        }
-        result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
-        break
-      }
-
-      case "choco":
-        result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
-        break
-      case "scoop":
-        result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
-        break
-      default:
-        throw new Error(`Unknown method: ${method}`)
-    }
-    if (!result || result.code !== 0) {
-      const stderr =
-        method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
-      throw new UpgradeFailedError({
-        stderr: stderr,
-      })
-    }
-    log.info("upgraded", {
-      method,
-      target,
-      stdout: result.stdout.toString(),
-      stderr: result.stderr.toString(),
-    })
-    await Process.text([process.execPath, "--version"], { nothrow: true })
+          if (!result || result.code !== 0) {
+            const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
+            return yield* new UpgradeFailedError({ stderr })
+          }
+          log.info("upgraded", {
+            method: m,
+            target,
+            stdout: result.stdout,
+            stderr: result.stderr,
+          })
+          yield* text([process.execPath, "--version"])
+        })
+
+        return Service.of({
+          info: Effect.fn("Installation.info")(function* () {
+            return {
+              version: VERSION,
+              latest: yield* latestImpl(),
+            }
+          }),
+          method: methodImpl,
+          latest: latestImpl,
+          upgrade: upgradeImpl,
+        })
+      }),
+    )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(FetchHttpClient.layer),
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
+
+  // Legacy adapters — dynamic import avoids circular dependency since
+  // foundational modules (db.ts, provider/models.ts) import Installation
+  // at load time, and runtime transitively loads those same modules.
+  async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
+    const { runtime } = await import("@/effect/runtime")
+    return runtime.runPromise(Service.use(f))
   }
 
-  export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
-  export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
-  export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
+  export function info(): Promise<Info> {
+    return runPromise((svc) => svc.info())
+  }
 
-  export async function latest(installMethod?: Method) {
-    const detectedMethod = installMethod || (await method())
-
-    if (detectedMethod === "brew") {
-      const formula = await getBrewFormula()
-      if (formula.includes("/")) {
-        const infoJson = await text(["brew", "info", "--json=v2", formula])
-        const info = JSON.parse(infoJson)
-        const version = info.formulae?.[0]?.versions?.stable
-        if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
-        return version
-      }
-      return fetch("https://formulae.brew.sh/api/formula/opencode.json")
-        .then((res) => {
-          if (!res.ok) throw new Error(res.statusText)
-          return res.json()
-        })
-        .then((data: any) => data.versions.stable)
-    }
-
-    if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
-      const registry = await iife(async () => {
-        const r = (await text(["npm", "config", "get", "registry"])).trim()
-        const reg = r || "https://registry.npmjs.org"
-        return reg.endsWith("/") ? reg.slice(0, -1) : reg
-      })
-      const channel = CHANNEL
-      return fetch(`${registry}/opencode-ai/${channel}`)
-        .then((res) => {
-          if (!res.ok) throw new Error(res.statusText)
-          return res.json()
-        })
-        .then((data: any) => data.version)
-    }
-
-    if (detectedMethod === "choco") {
-      return fetch(
-        "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
-        { headers: { Accept: "application/json;odata=verbose" } },
-      )
-        .then((res) => {
-          if (!res.ok) throw new Error(res.statusText)
-          return res.json()
-        })
-        .then((data: any) => data.d.results[0].Version)
-    }
-
-    if (detectedMethod === "scoop") {
-      return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", {
-        headers: { Accept: "application/json" },
-      })
-        .then((res) => {
-          if (!res.ok) throw new Error(res.statusText)
-          return res.json()
-        })
-        .then((data: any) => data.version)
-    }
-
-    return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest")
-      .then((res) => {
-        if (!res.ok) throw new Error(res.statusText)
-        return res.json()
-      })
-      .then((data: any) => data.tag_name.replace(/^v/, ""))
+  export function method(): Promise<Method> {
+    return runPromise((svc) => svc.method())
+  }
+
+  export function latest(installMethod?: Method): Promise<string> {
+    return runPromise((svc) => svc.latest(installMethod))
+  }
+
+  export function upgrade(m: Method, target: string): Promise<void> {
+    return runPromise((svc) => svc.upgrade(m, target))
   }
 }

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

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

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

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

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

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

+ 30 - 273
packages/opencode/src/permission/index.ts

@@ -1,295 +1,52 @@
 import { runPromiseInstance } from "@/effect/runtime"
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { Config } from "@/config/config"
-import { InstanceContext } from "@/effect/instance-context"
-import { ProjectID } from "@/project/schema"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
 import { fn } from "@/util/fn"
-import { Log } from "@/util/log"
-import { Wildcard } from "@/util/wildcard"
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import os from "os"
 import z from "zod"
-import { PermissionID } from "./schema"
+import { Permission as S } from "./service"
 
 export namespace PermissionNext {
-  const log = Log.create({ service: "permission" })
+  export const Action = S.Action
+  export type Action = S.Action
 
-  export const Action = z.enum(["allow", "deny", "ask"]).meta({
-    ref: "PermissionAction",
-  })
-  export type Action = z.infer<typeof Action>
+  export const Rule = S.Rule
+  export type Rule = S.Rule
 
-  export const Rule = z
-    .object({
-      permission: z.string(),
-      pattern: z.string(),
-      action: Action,
-    })
-    .meta({
-      ref: "PermissionRule",
-    })
-  export type Rule = z.infer<typeof Rule>
+  export const Ruleset = S.Ruleset
+  export type Ruleset = S.Ruleset
 
-  export const Ruleset = Rule.array().meta({
-    ref: "PermissionRuleset",
-  })
-  export type Ruleset = z.infer<typeof Ruleset>
+  export const Request = S.Request
+  export type Request = S.Request
 
-  export const Request = z
-    .object({
-      id: PermissionID.zod,
-      sessionID: SessionID.zod,
-      permission: z.string(),
-      patterns: z.string().array(),
-      metadata: z.record(z.string(), z.any()),
-      always: z.string().array(),
-      tool: z
-        .object({
-          messageID: MessageID.zod,
-          callID: z.string(),
-        })
-        .optional(),
-    })
-    .meta({
-      ref: "PermissionRequest",
-    })
-  export type Request = z.infer<typeof Request>
+  export const Reply = S.Reply
+  export type Reply = S.Reply
 
-  export const Reply = z.enum(["once", "always", "reject"])
-  export type Reply = z.infer<typeof Reply>
+  export const Approval = S.Approval
+  export type Approval = z.infer<typeof S.Approval>
 
-  export const Approval = z.object({
-    projectID: ProjectID.zod,
-    patterns: z.string().array(),
-  })
+  export const Event = S.Event
 
-  export const Event = {
-    Asked: BusEvent.define("permission.asked", Request),
-    Replied: BusEvent.define(
-      "permission.replied",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: PermissionID.zod,
-        reply: Reply,
-      }),
-    ),
-  }
-
-  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
-    override get message() {
-      return "The user rejected permission to use this specific tool call."
-    }
-  }
-
-  export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
-    feedback: Schema.String,
-  }) {
-    override get message() {
-      return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
-    }
-  }
-
-  export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
-    ruleset: Schema.Any,
-  }) {
-    override get message() {
-      return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
-    }
-  }
-
-  export type Error = DeniedError | RejectedError | CorrectedError
-
-  export const AskInput = Request.partial({ id: true }).extend({
-    ruleset: Ruleset,
-  })
-
-  export const ReplyInput = z.object({
-    requestID: PermissionID.zod,
-    reply: Reply,
-    message: z.string().optional(),
-  })
-
-  export interface Interface {
-    readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
-    readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
-  }
-
-  interface PendingEntry {
-    info: Request
-    deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
-  }
-
-  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-    const rules = rulesets.flat()
-    log.info("evaluate", { permission, pattern, ruleset: rules })
-    const match = rules.findLast(
-      (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
-    )
-    return match ?? { action: "ask", permission, pattern: "*" }
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const { project } = yield* InstanceContext
-      const row = Database.use((db) =>
-        db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
-      )
-      const pending = new Map<PermissionID, PendingEntry>()
-      const approved: Ruleset = row?.data ?? []
-
-      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
-        const { ruleset, ...request } = input
-        let needsAsk = false
+  export const RejectedError = S.RejectedError
+  export const CorrectedError = S.CorrectedError
+  export const DeniedError = S.DeniedError
+  export type Error = S.Error
 
-        for (const pattern of request.patterns) {
-          const rule = evaluate(request.permission, pattern, ruleset, approved)
-          log.info("evaluated", { permission: request.permission, pattern, action: rule })
-          if (rule.action === "deny") {
-            return yield* new DeniedError({
-              ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
-            })
-          }
-          if (rule.action === "allow") continue
-          needsAsk = true
-        }
+  export const AskInput = S.AskInput
+  export const ReplyInput = S.ReplyInput
 
-        if (!needsAsk) return
+  export type Interface = S.Interface
 
-        const id = request.id ?? PermissionID.ascending()
-        const info: Request = {
-          id,
-          ...request,
-        }
-        log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+  export const Service = S.Service
+  export const layer = S.layer
 
-        const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
-        pending.set(id, { info, deferred })
-        void Bus.publish(Event.Asked, info)
-        return yield* Effect.ensuring(
-          Deferred.await(deferred),
-          Effect.sync(() => {
-            pending.delete(id)
-          }),
-        )
-      })
+  export const evaluate = S.evaluate
+  export const fromConfig = S.fromConfig
+  export const merge = S.merge
+  export const disabled = S.disabled
 
-      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
-        const existing = pending.get(input.requestID)
-        if (!existing) return
+  export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
 
-        pending.delete(input.requestID)
-        void Bus.publish(Event.Replied, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-          reply: input.reply,
-        })
-
-        if (input.reply === "reject") {
-          yield* Deferred.fail(
-            existing.deferred,
-            input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
-          )
-
-          for (const [id, item] of pending.entries()) {
-            if (item.info.sessionID !== existing.info.sessionID) continue
-            pending.delete(id)
-            void Bus.publish(Event.Replied, {
-              sessionID: item.info.sessionID,
-              requestID: item.info.id,
-              reply: "reject",
-            })
-            yield* Deferred.fail(item.deferred, new RejectedError())
-          }
-          return
-        }
-
-        yield* Deferred.succeed(existing.deferred, undefined)
-        if (input.reply === "once") return
-
-        for (const pattern of existing.info.always) {
-          approved.push({
-            permission: existing.info.permission,
-            pattern,
-            action: "allow",
-          })
-        }
-
-        for (const [id, item] of pending.entries()) {
-          if (item.info.sessionID !== existing.info.sessionID) continue
-          const ok = item.info.patterns.every(
-            (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
-          )
-          if (!ok) continue
-          pending.delete(id)
-          void Bus.publish(Event.Replied, {
-            sessionID: item.info.sessionID,
-            requestID: item.info.id,
-            reply: "always",
-          })
-          yield* Deferred.succeed(item.deferred, undefined)
-        }
-      })
-
-      const list = Effect.fn("Permission.list")(function* () {
-        return Array.from(pending.values(), (item) => item.info)
-      })
-
-      return Service.of({ ask, reply, list })
-    }),
-  )
-
-  function expand(pattern: string): string {
-    if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
-    if (pattern === "~") return os.homedir()
-    if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
-    if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
-    return pattern
-  }
-
-  export function fromConfig(permission: Config.Permission) {
-    const ruleset: Ruleset = []
-    for (const [key, value] of Object.entries(permission)) {
-      if (typeof value === "string") {
-        ruleset.push({ permission: key, action: value, pattern: "*" })
-        continue
-      }
-      ruleset.push(
-        ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
-      )
-    }
-    return ruleset
-  }
-
-  export function merge(...rulesets: Ruleset[]): Ruleset {
-    return rulesets.flat()
-  }
-
-  export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
-
-  export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
+  export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
 
   export async function list() {
-    return runPromiseInstance(Service.use((svc) => svc.list()))
-  }
-
-  const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
-
-  export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
-    const result = new Set<string>()
-    for (const tool of tools) {
-      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
-      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
-      if (!rule) continue
-      if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
-    }
-    return result
+    return runPromiseInstance(S.Service.use((s) => s.list()))
   }
 }

+ 282 - 0
packages/opencode/src/permission/service.ts

@@ -0,0 +1,282 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config/config"
+import { InstanceContext } from "@/effect/instance-context"
+import { ProjectID } from "@/project/schema"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import os from "os"
+import z from "zod"
+import { evaluate as evalRule } from "./evaluate"
+import { PermissionID } from "./schema"
+
+export namespace Permission {
+  const log = Log.create({ service: "permission" })
+
+  export const Action = z.enum(["allow", "deny", "ask"]).meta({
+    ref: "PermissionAction",
+  })
+  export type Action = z.infer<typeof Action>
+
+  export const Rule = z
+    .object({
+      permission: z.string(),
+      pattern: z.string(),
+      action: Action,
+    })
+    .meta({
+      ref: "PermissionRule",
+    })
+  export type Rule = z.infer<typeof Rule>
+
+  export const Ruleset = Rule.array().meta({
+    ref: "PermissionRuleset",
+  })
+  export type Ruleset = z.infer<typeof Ruleset>
+
+  export const Request = z
+    .object({
+      id: PermissionID.zod,
+      sessionID: SessionID.zod,
+      permission: z.string(),
+      patterns: z.string().array(),
+      metadata: z.record(z.string(), z.any()),
+      always: z.string().array(),
+      tool: z
+        .object({
+          messageID: MessageID.zod,
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({
+      ref: "PermissionRequest",
+    })
+  export type Request = z.infer<typeof Request>
+
+  export const Reply = z.enum(["once", "always", "reject"])
+  export type Reply = z.infer<typeof Reply>
+
+  export const Approval = z.object({
+    projectID: ProjectID.zod,
+    patterns: z.string().array(),
+  })
+
+  export const Event = {
+    Asked: BusEvent.define("permission.asked", Request),
+    Replied: BusEvent.define(
+      "permission.replied",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: PermissionID.zod,
+        reply: Reply,
+      }),
+    ),
+  }
+
+  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+    override get message() {
+      return "The user rejected permission to use this specific tool call."
+    }
+  }
+
+  export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+    feedback: Schema.String,
+  }) {
+    override get message() {
+      return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+    }
+  }
+
+  export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+    ruleset: Schema.Any,
+  }) {
+    override get message() {
+      return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+    }
+  }
+
+  export type Error = DeniedError | RejectedError | CorrectedError
+
+  export const AskInput = Request.partial({ id: true }).extend({
+    ruleset: Ruleset,
+  })
+
+  export const ReplyInput = z.object({
+    requestID: PermissionID.zod,
+    reply: Reply,
+    message: z.string().optional(),
+  })
+
+  export interface Interface {
+    readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
+    readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<Request[]>
+  }
+
+  interface PendingEntry {
+    info: Request
+    deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+  }
+
+  export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+    log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
+    return evalRule(permission, pattern, ...rulesets)
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const { project } = yield* InstanceContext
+      const row = Database.use((db) =>
+        db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
+      )
+      const pending = new Map<PermissionID, PendingEntry>()
+      const approved: Ruleset = row?.data ?? []
+
+      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
+        const { ruleset, ...request } = input
+        let needsAsk = false
+
+        for (const pattern of request.patterns) {
+          const rule = evaluate(request.permission, pattern, ruleset, approved)
+          log.info("evaluated", { permission: request.permission, pattern, action: rule })
+          if (rule.action === "deny") {
+            return yield* new DeniedError({
+              ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
+            })
+          }
+          if (rule.action === "allow") continue
+          needsAsk = true
+        }
+
+        if (!needsAsk) return
+
+        const id = request.id ?? PermissionID.ascending()
+        const info: Request = {
+          id,
+          ...request,
+        }
+        log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+
+        const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+        pending.set(id, { info, deferred })
+        void Bus.publish(Event.Asked, info)
+        return yield* Effect.ensuring(
+          Deferred.await(deferred),
+          Effect.sync(() => {
+            pending.delete(id)
+          }),
+        )
+      })
+
+      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
+        const existing = pending.get(input.requestID)
+        if (!existing) return
+
+        pending.delete(input.requestID)
+        void Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          reply: input.reply,
+        })
+
+        if (input.reply === "reject") {
+          yield* Deferred.fail(
+            existing.deferred,
+            input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+          )
+
+          for (const [id, item] of pending.entries()) {
+            if (item.info.sessionID !== existing.info.sessionID) continue
+            pending.delete(id)
+            void Bus.publish(Event.Replied, {
+              sessionID: item.info.sessionID,
+              requestID: item.info.id,
+              reply: "reject",
+            })
+            yield* Deferred.fail(item.deferred, new RejectedError())
+          }
+          return
+        }
+
+        yield* Deferred.succeed(existing.deferred, undefined)
+        if (input.reply === "once") return
+
+        for (const pattern of existing.info.always) {
+          approved.push({
+            permission: existing.info.permission,
+            pattern,
+            action: "allow",
+          })
+        }
+
+        for (const [id, item] of pending.entries()) {
+          if (item.info.sessionID !== existing.info.sessionID) continue
+          const ok = item.info.patterns.every(
+            (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
+          )
+          if (!ok) continue
+          pending.delete(id)
+          void Bus.publish(Event.Replied, {
+            sessionID: item.info.sessionID,
+            requestID: item.info.id,
+            reply: "always",
+          })
+          yield* Deferred.succeed(item.deferred, undefined)
+        }
+      })
+
+      const list = Effect.fn("Permission.list")(function* () {
+        return Array.from(pending.values(), (item) => item.info)
+      })
+
+      return Service.of({ ask, reply, list })
+    }),
+  ).pipe(Layer.fresh)
+
+  function expand(pattern: string): string {
+    if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
+    if (pattern === "~") return os.homedir()
+    if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
+    if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
+    return pattern
+  }
+
+  export function fromConfig(permission: Config.Permission) {
+    const ruleset: Ruleset = []
+    for (const [key, value] of Object.entries(permission)) {
+      if (typeof value === "string") {
+        ruleset.push({ permission: key, action: value, pattern: "*" })
+        continue
+      }
+      ruleset.push(
+        ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
+      )
+    }
+    return ruleset
+  }
+
+  export function merge(...rulesets: Ruleset[]): Ruleset {
+    return rulesets.flat()
+  }
+
+  const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+
+  export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+    const result = new Set<string>()
+    for (const tool of tools) {
+      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+      if (!rule) continue
+      if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
+    }
+    return result
+  }
+}

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

@@ -11,7 +11,7 @@ import { CodexAuthPlugin } from "./codex"
 import { Session } from "../session"
 import { NamedError } from "@opencode-ai/util/error"
 import { CopilotAuthPlugin } from "./copilot"
-import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
+import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })

+ 1 - 1
packages/opencode/src/project/vcs.ts

@@ -79,5 +79,5 @@ export namespace Vcs {
         }),
       })
     }),
-  )
+  ).pipe(Layer.fresh)
 }

+ 215 - 0
packages/opencode/src/provider/auth-service.ts

@@ -0,0 +1,215 @@
+import type { AuthOuathResult } from "@opencode-ai/plugin"
+import { NamedError } from "@opencode-ai/util/error"
+import * as Auth from "@/auth/effect"
+import { ProviderID } from "./schema"
+import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
+import z from "zod"
+
+export namespace ProviderAuth {
+  export const Method = z
+    .object({
+      type: z.union([z.literal("oauth"), z.literal("api")]),
+      label: z.string(),
+      prompts: z
+        .array(
+          z.union([
+            z.object({
+              type: z.literal("text"),
+              key: z.string(),
+              message: z.string(),
+              placeholder: z.string().optional(),
+              when: z
+                .object({
+                  key: z.string(),
+                  op: z.union([z.literal("eq"), z.literal("neq")]),
+                  value: z.string(),
+                })
+                .optional(),
+            }),
+            z.object({
+              type: z.literal("select"),
+              key: z.string(),
+              message: z.string(),
+              options: z.array(
+                z.object({
+                  label: z.string(),
+                  value: z.string(),
+                  hint: z.string().optional(),
+                }),
+              ),
+              when: z
+                .object({
+                  key: z.string(),
+                  op: z.union([z.literal("eq"), z.literal("neq")]),
+                  value: z.string(),
+                })
+                .optional(),
+            }),
+          ]),
+        )
+        .optional(),
+    })
+    .meta({
+      ref: "ProviderAuthMethod",
+    })
+  export type Method = z.infer<typeof Method>
+
+  export const Authorization = z
+    .object({
+      url: z.string(),
+      method: z.union([z.literal("auto"), z.literal("code")]),
+      instructions: z.string(),
+    })
+    .meta({
+      ref: "ProviderAuthAuthorization",
+    })
+  export type Authorization = z.infer<typeof Authorization>
+
+  export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
+
+  export const OauthCodeMissing = NamedError.create(
+    "ProviderAuthOauthCodeMissing",
+    z.object({ providerID: ProviderID.zod }),
+  )
+
+  export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
+
+  export const ValidationFailed = NamedError.create(
+    "ProviderAuthValidationFailed",
+    z.object({
+      field: z.string(),
+      message: z.string(),
+    }),
+  )
+
+  export type Error =
+    | Auth.AuthError
+    | InstanceType<typeof OauthMissing>
+    | InstanceType<typeof OauthCodeMissing>
+    | InstanceType<typeof OauthCallbackFailed>
+    | InstanceType<typeof ValidationFailed>
+
+  export interface Interface {
+    readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
+    readonly authorize: (input: {
+      providerID: ProviderID
+      method: number
+      inputs?: Record<string, string>
+    }) => Effect.Effect<Authorization | undefined, Error>
+    readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const auth = yield* Auth.Auth.Service
+      const hooks = yield* Effect.promise(async () => {
+        const mod = await import("../plugin")
+        const plugins = await mod.Plugin.list()
+        return Record.fromEntries(
+          Arr.filterMap(plugins, (x) =>
+            x.auth?.provider !== undefined
+              ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
+              : Result.failVoid,
+          ),
+        )
+      })
+      const pending = new Map<ProviderID, AuthOuathResult>()
+
+      const methods = Effect.fn("ProviderAuth.methods")(function* () {
+        return Record.map(hooks, (item) =>
+          item.methods.map(
+            (method): Method => ({
+              type: method.type,
+              label: method.label,
+              prompts: method.prompts?.map((prompt) => {
+                if (prompt.type === "select") {
+                  return {
+                    type: "select" as const,
+                    key: prompt.key,
+                    message: prompt.message,
+                    options: prompt.options,
+                    when: prompt.when,
+                  }
+                }
+                return {
+                  type: "text" as const,
+                  key: prompt.key,
+                  message: prompt.message,
+                  placeholder: prompt.placeholder,
+                  when: prompt.when,
+                }
+              }),
+            }),
+          ),
+        )
+      })
+
+      const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
+        providerID: ProviderID
+        method: number
+        inputs?: Record<string, string>
+      }) {
+        const method = hooks[input.providerID].methods[input.method]
+        if (method.type !== "oauth") return
+
+        if (method.prompts && input.inputs) {
+          for (const prompt of method.prompts) {
+            if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
+              const error = prompt.validate(input.inputs[prompt.key])
+              if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
+            }
+          }
+        }
+
+        const result = yield* Effect.promise(() => method.authorize(input.inputs))
+        pending.set(input.providerID, result)
+        return {
+          url: result.url,
+          method: result.method,
+          instructions: result.instructions,
+        }
+      })
+
+      const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
+        providerID: ProviderID
+        method: number
+        code?: string
+      }) {
+        const match = pending.get(input.providerID)
+        if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
+        if (match.method === "code" && !input.code) {
+          return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
+        }
+
+        const result = yield* Effect.promise(() =>
+          match.method === "code" ? match.callback(input.code!) : match.callback(),
+        )
+        if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
+
+        if ("key" in result) {
+          yield* auth.set(input.providerID, {
+            type: "api",
+            key: result.key,
+          })
+        }
+
+        if ("refresh" in result) {
+          yield* auth.set(input.providerID, {
+            type: "oauth",
+            access: result.access,
+            refresh: result.refresh,
+            expires: result.expires,
+            ...(result.accountId ? { accountId: result.accountId } : {}),
+          })
+        }
+      })
+
+      return Service.of({ methods, authorize, callback })
+    }),
+  ).pipe(Layer.fresh)
+
+  export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
+}

+ 18 - 209
packages/opencode/src/provider/auth.ts

@@ -1,222 +1,30 @@
-import type { AuthOuathResult } from "@opencode-ai/plugin"
-import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/effect"
 import { runPromiseInstance } from "@/effect/runtime"
 import { fn } from "@/util/fn"
 import { ProviderID } from "./schema"
-import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
 import z from "zod"
+import { ProviderAuth as S } from "./auth-service"
 
 export namespace ProviderAuth {
-  export const Method = z
-    .object({
-      type: z.union([z.literal("oauth"), z.literal("api")]),
-      label: z.string(),
-      prompts: z
-        .array(
-          z.union([
-            z.object({
-              type: z.literal("text"),
-              key: z.string(),
-              message: z.string(),
-              placeholder: z.string().optional(),
-              when: z
-                .object({
-                  key: z.string(),
-                  op: z.union([z.literal("eq"), z.literal("neq")]),
-                  value: z.string(),
-                })
-                .optional(),
-            }),
-            z.object({
-              type: z.literal("select"),
-              key: z.string(),
-              message: z.string(),
-              options: z.array(
-                z.object({
-                  label: z.string(),
-                  value: z.string(),
-                  hint: z.string().optional(),
-                }),
-              ),
-              when: z
-                .object({
-                  key: z.string(),
-                  op: z.union([z.literal("eq"), z.literal("neq")]),
-                  value: z.string(),
-                })
-                .optional(),
-            }),
-          ]),
-        )
-        .optional(),
-    })
-    .meta({
-      ref: "ProviderAuthMethod",
-    })
-  export type Method = z.infer<typeof Method>
+  export const Method = S.Method
+  export type Method = S.Method
 
-  export const Authorization = z
-    .object({
-      url: z.string(),
-      method: z.union([z.literal("auto"), z.literal("code")]),
-      instructions: z.string(),
-    })
-    .meta({
-      ref: "ProviderAuthAuthorization",
-    })
-  export type Authorization = z.infer<typeof Authorization>
+  export const Authorization = S.Authorization
+  export type Authorization = S.Authorization
 
-  export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
+  export const OauthMissing = S.OauthMissing
+  export const OauthCodeMissing = S.OauthCodeMissing
+  export const OauthCallbackFailed = S.OauthCallbackFailed
+  export const ValidationFailed = S.ValidationFailed
+  export type Error = S.Error
 
-  export const OauthCodeMissing = NamedError.create(
-    "ProviderAuthOauthCodeMissing",
-    z.object({ providerID: ProviderID.zod }),
-  )
-
-  export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
-
-  export const ValidationFailed = NamedError.create(
-    "ProviderAuthValidationFailed",
-    z.object({
-      field: z.string(),
-      message: z.string(),
-    }),
-  )
-
-  export type Error =
-    | Auth.AuthError
-    | InstanceType<typeof OauthMissing>
-    | InstanceType<typeof OauthCodeMissing>
-    | InstanceType<typeof OauthCallbackFailed>
-    | InstanceType<typeof ValidationFailed>
-
-  export interface Interface {
-    readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
-    readonly authorize: (input: {
-      providerID: ProviderID
-      method: number
-      inputs?: Record<string, string>
-    }) => Effect.Effect<Authorization | undefined, Error>
-    readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const auth = yield* Auth.AuthEffect.Service
-      const hooks = yield* Effect.promise(async () => {
-        const mod = await import("../plugin")
-        const plugins = await mod.Plugin.list()
-        return Record.fromEntries(
-          Arr.filterMap(plugins, (x) =>
-            x.auth?.provider !== undefined
-              ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
-              : Result.failVoid,
-          ),
-        )
-      })
-      const pending = new Map<ProviderID, AuthOuathResult>()
-
-      const methods = Effect.fn("ProviderAuth.methods")(function* () {
-        return Record.map(hooks, (item) =>
-          item.methods.map(
-            (method): Method => ({
-              type: method.type,
-              label: method.label,
-              prompts: method.prompts?.map((prompt) => {
-                if (prompt.type === "select") {
-                  return {
-                    type: "select" as const,
-                    key: prompt.key,
-                    message: prompt.message,
-                    options: prompt.options,
-                    when: prompt.when,
-                  }
-                }
-                return {
-                  type: "text" as const,
-                  key: prompt.key,
-                  message: prompt.message,
-                  placeholder: prompt.placeholder,
-                  when: prompt.when,
-                }
-              }),
-            }),
-          ),
-        )
-      })
-
-      const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
-        providerID: ProviderID
-        method: number
-        inputs?: Record<string, string>
-      }) {
-        const method = hooks[input.providerID].methods[input.method]
-        if (method.type !== "oauth") return
-
-        if (method.prompts && input.inputs) {
-          for (const prompt of method.prompts) {
-            if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
-              const error = prompt.validate(input.inputs[prompt.key])
-              if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
-            }
-          }
-        }
-
-        const result = yield* Effect.promise(() => method.authorize(input.inputs))
-        pending.set(input.providerID, result)
-        return {
-          url: result.url,
-          method: result.method,
-          instructions: result.instructions,
-        }
-      })
-
-      const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
-        providerID: ProviderID
-        method: number
-        code?: string
-      }) {
-        const match = pending.get(input.providerID)
-        if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
-        if (match.method === "code" && !input.code) {
-          return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
-        }
-
-        const result = yield* Effect.promise(() =>
-          match.method === "code" ? match.callback(input.code!) : match.callback(),
-        )
-        if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
-
-        if ("key" in result) {
-          yield* auth.set(input.providerID, {
-            type: "api",
-            key: result.key,
-          })
-        }
-
-        if ("refresh" in result) {
-          yield* auth.set(input.providerID, {
-            type: "oauth",
-            access: result.access,
-            refresh: result.refresh,
-            expires: result.expires,
-            ...(result.accountId ? { accountId: result.accountId } : {}),
-          })
-        }
-      })
-
-      return Service.of({ methods, authorize, callback })
-    }),
-  )
+  export type Interface = S.Interface
 
-  export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
+  export const Service = S.Service
+  export const layer = S.layer
+  export const defaultLayer = S.defaultLayer
 
   export async function methods() {
-    return runPromiseInstance(Service.use((svc) => svc.methods()))
+    return runPromiseInstance(S.Service.use((svc) => svc.methods()))
   }
 
   export const authorize = fn(
@@ -225,7 +33,8 @@ export namespace ProviderAuth {
       method: z.number(),
       inputs: z.record(z.string(), z.string()).optional(),
     }),
-    async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
+    async (input): Promise<Authorization | undefined> =>
+      runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
   )
 
   export const callback = fn(
@@ -234,6 +43,6 @@ export namespace ProviderAuth {
       method: z.number(),
       code: z.string().optional(),
     }),
-    async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
+    async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
   )
 }

+ 123 - 14
packages/opencode/src/provider/provider.ts

@@ -40,7 +40,12 @@ import { createGateway } from "@ai-sdk/gateway"
 import { createTogetherAI } from "@ai-sdk/togetherai"
 import { createPerplexity } from "@ai-sdk/perplexity"
 import { createVercel } from "@ai-sdk/vercel"
-import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
+import {
+  createGitLab,
+  VERSION as GITLAB_PROVIDER_VERSION,
+  isWorkflowModel,
+  discoverWorkflowModels,
+} from "gitlab-ai-provider"
 import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
 import { GoogleAuth } from "google-auth-library"
 import { ProviderTransform } from "./transform"
@@ -124,18 +129,20 @@ export namespace Provider {
     "@ai-sdk/togetherai": createTogetherAI,
     "@ai-sdk/perplexity": createPerplexity,
     "@ai-sdk/vercel": createVercel,
-    "@gitlab/gitlab-ai-provider": createGitLab,
+    "gitlab-ai-provider": createGitLab,
     // @ts-ignore (TODO: kill this code so we dont have to maintain it)
     "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
   }
 
   type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
   type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
+  type CustomDiscoverModels = () => Promise<Record<string, Model>>
   type CustomLoader = (provider: Info) => Promise<{
     autoload: boolean
     getModel?: CustomModelLoader
     vars?: CustomVarsLoader
     options?: Record<string, any>
+    discoverModels?: CustomDiscoverModels
   }>
 
   function useLanguageModel(sdk: any) {
@@ -184,6 +191,15 @@ export namespace Provider {
         options: {},
       }
     },
+    xai: async () => {
+      return {
+        autoload: false,
+        async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
+          return sdk.responses(modelID)
+        },
+        options: {},
+      }
+    },
     "github-copilot": async () => {
       return {
         autoload: false,
@@ -524,28 +540,105 @@ export namespace Provider {
         ...(providerConfig?.options?.aiGatewayHeaders || {}),
       }
 
+      const featureFlags = {
+        duo_agent_platform_agentic_chat: true,
+        duo_agent_platform: true,
+        ...(providerConfig?.options?.featureFlags || {}),
+      }
+
       return {
         autoload: !!apiKey,
         options: {
           instanceUrl,
           apiKey,
           aiGatewayHeaders,
-          featureFlags: {
-            duo_agent_platform_agentic_chat: true,
-            duo_agent_platform: true,
-            ...(providerConfig?.options?.featureFlags || {}),
-          },
+          featureFlags,
         },
-        async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
+        async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string, options?: Record<string, any>) {
+          if (modelID.startsWith("duo-workflow-")) {
+            const workflowRef = options?.workflowRef as string | undefined
+            // Use the static mapping if it exists, otherwise use duo-workflow with selectedModelRef
+            const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
+            const model = sdk.workflowChat(sdkModelID, {
+              featureFlags,
+            })
+            if (workflowRef) {
+              model.selectedModelRef = workflowRef
+            }
+            return model
+          }
           return sdk.agenticChat(modelID, {
             aiGatewayHeaders,
-            featureFlags: {
-              duo_agent_platform_agentic_chat: true,
-              duo_agent_platform: true,
-              ...(providerConfig?.options?.featureFlags || {}),
-            },
+            featureFlags,
           })
         },
+        async discoverModels(): Promise<Record<string, Model>> {
+          if (!apiKey) {
+            log.info("gitlab model discovery skipped: no apiKey")
+            return {}
+          }
+
+          try {
+            const token = apiKey
+            const getHeaders = (): Record<string, string> =>
+              auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
+
+            log.info("gitlab model discovery starting", { instanceUrl })
+            const result = await discoverWorkflowModels(
+              { instanceUrl, getHeaders },
+              { workingDirectory: Instance.directory },
+            )
+
+            if (!result.models.length) {
+              log.info("gitlab model discovery skipped: no models found", {
+                project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null,
+              })
+              return {}
+            }
+
+            const models: Record<string, Model> = {}
+            for (const m of result.models) {
+              if (!input.models[m.id]) {
+                models[m.id] = {
+                  id: ModelID.make(m.id),
+                  providerID: ProviderID.make("gitlab"),
+                  name: `Agent Platform (${m.name})`,
+                  family: "",
+                  api: {
+                    id: m.id,
+                    url: instanceUrl,
+                    npm: "gitlab-ai-provider",
+                  },
+                  status: "active",
+                  headers: {},
+                  options: { workflowRef: m.ref },
+                  cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
+                  limit: { context: m.context, output: m.output },
+                  capabilities: {
+                    temperature: false,
+                    reasoning: true,
+                    attachment: true,
+                    toolcall: true,
+                    input: { text: true, audio: false, image: true, video: false, pdf: true },
+                    output: { text: true, audio: false, image: false, video: false, pdf: false },
+                    interleaved: false,
+                  },
+                  release_date: "",
+                  variants: {},
+                }
+              }
+            }
+
+            log.info("gitlab model discovery complete", {
+              count: Object.keys(models).length,
+              models: Object.keys(models),
+            })
+            return models
+          } catch (e) {
+            log.warn("gitlab model discovery failed", { error: e })
+            return {}
+          }
+        },
       }
     },
     "cloudflare-workers-ai": async (input) => {
@@ -844,6 +937,9 @@ export namespace Provider {
     const varsLoaders: {
       [providerID: string]: CustomVarsLoader
     } = {}
+    const discoveryLoaders: {
+      [providerID: string]: CustomDiscoverModels
+    } = {}
     const sdk = new Map<string, SDK>()
 
     log.info("init")
@@ -1000,6 +1096,7 @@ export namespace Provider {
       if (result && (result.autoload || providers[providerID])) {
         if (result.getModel) modelLoaders[providerID] = result.getModel
         if (result.vars) varsLoaders[providerID] = result.vars
+        if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
         const opts = result.options ?? {}
         const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
         mergeProvider(providerID, patch)
@@ -1061,6 +1158,18 @@ export namespace Provider {
       log.info("found", { providerID })
     }
 
+    const gitlab = ProviderID.make("gitlab")
+    if (discoveryLoaders[gitlab] && providers[gitlab]) {
+      await (async () => {
+        const discovered = await discoveryLoaders[gitlab]()
+        for (const [modelID, model] of Object.entries(discovered)) {
+          if (!providers[gitlab].models[modelID]) {
+            providers[gitlab].models[modelID] = model
+          }
+        }
+      })().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
+    }
+
     return {
       models: languages,
       providers,
@@ -1241,7 +1350,7 @@ export namespace Provider {
 
     try {
       const language = s.modelLoaders[model.providerID]
-        ? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
+        ? await s.modelLoaders[model.providerID](sdk, model.api.id, { ...provider.options, ...model.options })
         : sdk.languageModel(model.api.id)
       s.models.set(key, language)
       return language

+ 25 - 169
packages/opencode/src/question/index.ts

@@ -1,193 +1,49 @@
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
 import { runPromiseInstance } from "@/effect/runtime"
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "@/session/schema"
-import { Log } from "@/util/log"
-import z from "zod"
-import { QuestionID } from "./schema"
-
-const log = Log.create({ service: "question" })
+import type { MessageID, SessionID } from "@/session/schema"
+import type { QuestionID } from "./schema"
+import { Question as S } from "./service"
 
 export namespace Question {
-  // Schemas
-
-  export const Option = z
-    .object({
-      label: z.string().describe("Display text (1-5 words, concise)"),
-      description: z.string().describe("Explanation of choice"),
-    })
-    .meta({ ref: "QuestionOption" })
-  export type Option = z.infer<typeof Option>
-
-  export const Info = z
-    .object({
-      question: z.string().describe("Complete question"),
-      header: z.string().describe("Very short label (max 30 chars)"),
-      options: z.array(Option).describe("Available choices"),
-      multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
-      custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
-    })
-    .meta({ ref: "QuestionInfo" })
-  export type Info = z.infer<typeof Info>
-
-  export const Request = z
-    .object({
-      id: QuestionID.zod,
-      sessionID: SessionID.zod,
-      questions: z.array(Info).describe("Questions to ask"),
-      tool: z
-        .object({
-          messageID: MessageID.zod,
-          callID: z.string(),
-        })
-        .optional(),
-    })
-    .meta({ ref: "QuestionRequest" })
-  export type Request = z.infer<typeof Request>
-
-  export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
-  export type Answer = z.infer<typeof Answer>
-
-  export const Reply = z.object({
-    answers: z
-      .array(Answer)
-      .describe("User answers in order of questions (each answer is an array of selected labels)"),
-  })
-  export type Reply = z.infer<typeof Reply>
-
-  export const Event = {
-    Asked: BusEvent.define("question.asked", Request),
-    Replied: BusEvent.define(
-      "question.replied",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: QuestionID.zod,
-        answers: z.array(Answer),
-      }),
-    ),
-    Rejected: BusEvent.define(
-      "question.rejected",
-      z.object({
-        sessionID: SessionID.zod,
-        requestID: QuestionID.zod,
-      }),
-    ),
-  }
-
-  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
-    override get message() {
-      return "The user dismissed this question"
-    }
-  }
-
-  interface PendingEntry {
-    info: Request
-    deferred: Deferred.Deferred<Answer[], RejectedError>
-  }
-
-  // Service
-
-  export interface Interface {
-    readonly ask: (input: {
-      sessionID: SessionID
-      questions: Info[]
-      tool?: { messageID: MessageID; callID: string }
-    }) => Effect.Effect<Answer[], RejectedError>
-    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
-    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const pending = new Map<QuestionID, PendingEntry>()
+  export const Option = S.Option
+  export type Option = S.Option
 
-      const ask = Effect.fn("Question.ask")(function* (input: {
-        sessionID: SessionID
-        questions: Info[]
-        tool?: { messageID: MessageID; callID: string }
-      }) {
-        const id = QuestionID.ascending()
-        log.info("asking", { id, questions: input.questions.length })
+  export const Info = S.Info
+  export type Info = S.Info
 
-        const deferred = yield* Deferred.make<Answer[], RejectedError>()
-        const info: Request = {
-          id,
-          sessionID: input.sessionID,
-          questions: input.questions,
-          tool: input.tool,
-        }
-        pending.set(id, { info, deferred })
-        Bus.publish(Event.Asked, info)
+  export const Request = S.Request
+  export type Request = S.Request
 
-        return yield* Effect.ensuring(
-          Deferred.await(deferred),
-          Effect.sync(() => {
-            pending.delete(id)
-          }),
-        )
-      })
+  export const Answer = S.Answer
+  export type Answer = S.Answer
 
-      const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
-        const existing = pending.get(input.requestID)
-        if (!existing) {
-          log.warn("reply for unknown request", { requestID: input.requestID })
-          return
-        }
-        pending.delete(input.requestID)
-        log.info("replied", { requestID: input.requestID, answers: input.answers })
-        Bus.publish(Event.Replied, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-          answers: input.answers,
-        })
-        yield* Deferred.succeed(existing.deferred, input.answers)
-      })
+  export const Reply = S.Reply
+  export type Reply = S.Reply
 
-      const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
-        const existing = pending.get(requestID)
-        if (!existing) {
-          log.warn("reject for unknown request", { requestID })
-          return
-        }
-        pending.delete(requestID)
-        log.info("rejected", { requestID })
-        Bus.publish(Event.Rejected, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-        })
-        yield* Deferred.fail(existing.deferred, new RejectedError())
-      })
+  export const Event = S.Event
+  export const RejectedError = S.RejectedError
 
-      const list = Effect.fn("Question.list")(function* () {
-        return Array.from(pending.values(), (x) => x.info)
-      })
+  export type Interface = S.Interface
 
-      return Service.of({ ask, reply, reject, list })
-    }),
-  )
+  export const Service = S.Service
+  export const layer = S.layer
 
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
     tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
-    return runPromiseInstance(Service.use((svc) => svc.ask(input)))
+    return runPromiseInstance(S.Service.use((s) => s.ask(input)))
   }
 
-  export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
-    return runPromiseInstance(Service.use((svc) => svc.reply(input)))
+  export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
+    return runPromiseInstance(S.Service.use((s) => s.reply(input)))
   }
 
-  export async function reject(requestID: QuestionID): Promise<void> {
-    return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
+  export async function reject(requestID: QuestionID) {
+    return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
   }
 
-  export async function list(): Promise<Request[]> {
-    return runPromiseInstance(Service.use((svc) => svc.list()))
+  export async function list() {
+    return runPromiseInstance(S.Service.use((s) => s.list()))
   }
 }

+ 172 - 0
packages/opencode/src/question/service.ts

@@ -0,0 +1,172 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { SessionID, MessageID } from "@/session/schema"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
+
+const log = Log.create({ service: "question" })
+
+export namespace Question {
+  // Schemas
+
+  export const Option = z
+    .object({
+      label: z.string().describe("Display text (1-5 words, concise)"),
+      description: z.string().describe("Explanation of choice"),
+    })
+    .meta({ ref: "QuestionOption" })
+  export type Option = z.infer<typeof Option>
+
+  export const Info = z
+    .object({
+      question: z.string().describe("Complete question"),
+      header: z.string().describe("Very short label (max 30 chars)"),
+      options: z.array(Option).describe("Available choices"),
+      multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+      custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+    })
+    .meta({ ref: "QuestionInfo" })
+  export type Info = z.infer<typeof Info>
+
+  export const Request = z
+    .object({
+      id: QuestionID.zod,
+      sessionID: SessionID.zod,
+      questions: z.array(Info).describe("Questions to ask"),
+      tool: z
+        .object({
+          messageID: MessageID.zod,
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({ ref: "QuestionRequest" })
+  export type Request = z.infer<typeof Request>
+
+  export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+  export type Answer = z.infer<typeof Answer>
+
+  export const Reply = z.object({
+    answers: z
+      .array(Answer)
+      .describe("User answers in order of questions (each answer is an array of selected labels)"),
+  })
+  export type Reply = z.infer<typeof Reply>
+
+  export const Event = {
+    Asked: BusEvent.define("question.asked", Request),
+    Replied: BusEvent.define(
+      "question.replied",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+        answers: z.array(Answer),
+      }),
+    ),
+    Rejected: BusEvent.define(
+      "question.rejected",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+      }),
+    ),
+  }
+
+  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
+    override get message() {
+      return "The user dismissed this question"
+    }
+  }
+
+  interface PendingEntry {
+    info: Request
+    deferred: Deferred.Deferred<Answer[], RejectedError>
+  }
+
+  // Service
+
+  export interface Interface {
+    readonly ask: (input: {
+      sessionID: SessionID
+      questions: Info[]
+      tool?: { messageID: MessageID; callID: string }
+    }) => Effect.Effect<Answer[], RejectedError>
+    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
+    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<Request[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const pending = new Map<QuestionID, PendingEntry>()
+
+      const ask = Effect.fn("Question.ask")(function* (input: {
+        sessionID: SessionID
+        questions: Info[]
+        tool?: { messageID: MessageID; callID: string }
+      }) {
+        const id = QuestionID.ascending()
+        log.info("asking", { id, questions: input.questions.length })
+
+        const deferred = yield* Deferred.make<Answer[], RejectedError>()
+        const info: Request = {
+          id,
+          sessionID: input.sessionID,
+          questions: input.questions,
+          tool: input.tool,
+        }
+        pending.set(id, { info, deferred })
+        Bus.publish(Event.Asked, info)
+
+        return yield* Effect.ensuring(
+          Deferred.await(deferred),
+          Effect.sync(() => {
+            pending.delete(id)
+          }),
+        )
+      })
+
+      const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+        const existing = pending.get(input.requestID)
+        if (!existing) {
+          log.warn("reply for unknown request", { requestID: input.requestID })
+          return
+        }
+        pending.delete(input.requestID)
+        log.info("replied", { requestID: input.requestID, answers: input.answers })
+        Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          answers: input.answers,
+        })
+        yield* Deferred.succeed(existing.deferred, input.answers)
+      })
+
+      const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
+        const existing = pending.get(requestID)
+        if (!existing) {
+          log.warn("reject for unknown request", { requestID })
+          return
+        }
+        pending.delete(requestID)
+        log.info("rejected", { requestID })
+        Bus.publish(Event.Rejected, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+        })
+        yield* Deferred.fail(existing.deferred, new RejectedError())
+      })
+
+      const list = Effect.fn("Question.list")(function* () {
+        return Array.from(pending.values(), (x) => x.info)
+      })
+
+      return Service.of({ ask, reply, reject, list })
+    }),
+  ).pipe(Layer.fresh)
+}

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

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

+ 3 - 0
packages/opencode/src/server/routes/provider.ts

@@ -9,6 +9,9 @@ import { ProviderID } from "../../provider/schema"
 import { mapValues } from "remeda"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
+import { Log } from "../../util/log"
+
+const log = Log.create({ service: "server" })
 
 export const ProviderRoutes = lazy(() =>
   new Hono()

+ 51 - 16
packages/opencode/src/session/llm.ts

@@ -12,6 +12,7 @@ import {
   jsonSchema,
 } from "ai"
 import { mergeDeep, pipe } from "remeda"
+import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
 import { ProviderTransform } from "@/provider/transform"
 import { Config } from "@/config/config"
 import { Instance } from "@/project/instance"
@@ -63,14 +64,14 @@ export namespace LLM {
       Provider.getProvider(input.model.providerID),
       Auth.get(input.model.providerID),
     ])
-    const isCodex = provider.id === "openai" && auth?.type === "oauth"
+    // TODO: move this to a proper hook
+    const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
 
-    const system = []
+    const system: string[] = []
     system.push(
       [
         // use agent prompt otherwise provider prompt
-        // For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
-        ...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
+        ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
         // any custom prompt passed into this call
         ...input.system,
         // any custom prompt from last user message
@@ -108,10 +109,22 @@ export namespace LLM {
       mergeDeep(input.agent.options),
       mergeDeep(variant),
     )
-    if (isCodex) {
-      options.instructions = SystemPrompt.instructions()
+    if (isOpenaiOauth) {
+      options.instructions = system.join("\n")
     }
 
+    const messages = isOpenaiOauth
+      ? input.messages
+      : [
+          ...system.map(
+            (x): ModelMessage => ({
+              role: "system",
+              content: x,
+            }),
+          ),
+          ...input.messages,
+        ]
+
     const params = await Plugin.trigger(
       "chat.params",
       {
@@ -146,7 +159,9 @@ export namespace LLM {
     )
 
     const maxOutputTokens =
-      isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model)
+      isOpenaiOauth || provider.id.includes("github-copilot")
+        ? undefined
+        : ProviderTransform.maxOutputTokens(input.model)
 
     const tools = await resolveTools(input)
 
@@ -170,6 +185,34 @@ export namespace LLM {
       })
     }
 
+    // Wire up toolExecutor for DWS workflow models so that tool calls
+    // from the workflow service are executed via opencode's tool system
+    // and results sent back over the WebSocket.
+    if (language instanceof GitLabWorkflowLanguageModel) {
+      const workflowModel = language
+      workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
+        const t = tools[toolName]
+        if (!t || !t.execute) {
+          return { result: "", error: `Unknown tool: ${toolName}` }
+        }
+        try {
+          const result = await t.execute!(JSON.parse(argsJson), {
+            toolCallId: _requestID,
+            messages: input.messages,
+            abortSignal: input.abort,
+          })
+          const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
+          return {
+            result: output,
+            metadata: typeof result === "object" ? result?.metadata : undefined,
+            title: typeof result === "object" ? result?.title : undefined,
+          }
+        } catch (e: any) {
+          return { result: "", error: e.message ?? String(e) }
+        }
+      }
+    }
+
     return streamText({
       onError(error) {
         l.error("stream error", {
@@ -217,15 +260,7 @@ export namespace LLM {
         ...headers,
       },
       maxRetries: input.retries ?? 0,
-      messages: [
-        ...system.map(
-          (x): ModelMessage => ({
-            role: "system",
-            content: x,
-          }),
-        ),
-        ...input.messages,
-      ],
+      messages,
       model: wrapLanguageModel({
         model: language,
         middleware: [

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

@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
 import { Storage } from "@/storage/storage"
 import { ProviderError } from "@/provider/error"
 import { iife } from "@/util/iife"
-import { type SystemError } from "bun"
+import type { SystemError } from "bun"
 import type { Provider } from "@/provider/provider"
 import { ModelID, ProviderID } from "@/provider/schema"
 

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

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

+ 0 - 0
packages/opencode/src/session/prompt/codex_header.txt → packages/opencode/src/session/prompt/codex.txt


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

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

+ 3 - 7
packages/opencode/src/session/system.ts

@@ -7,7 +7,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt"
 import PROMPT_BEAST from "./prompt/beast.txt"
 import PROMPT_GEMINI from "./prompt/gemini.txt"
 
-import PROMPT_CODEX from "./prompt/codex_header.txt"
+import PROMPT_CODEX from "./prompt/codex.txt"
 import PROMPT_TRINITY from "./prompt/trinity.txt"
 import type { Provider } from "@/provider/provider"
 import type { Agent } from "@/agent/agent"
@@ -15,14 +15,10 @@ import { PermissionNext } from "@/permission"
 import { Skill } from "@/skill"
 
 export namespace SystemPrompt {
-  export function instructions() {
-    return PROMPT_CODEX.trim()
-  }
-
   export function provider(model: Provider.Model) {
-    if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
-    if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
+    if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
       return [PROMPT_BEAST]
+    if (model.api.id.includes("gpt")) return [PROMPT_CODEX]
     if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
     if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
     if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]

+ 238 - 0
packages/opencode/src/skill/service.ts

@@ -0,0 +1,238 @@
+import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
+import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import { NamedError } from "@opencode-ai/util/error"
+import type { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { InstanceContext } from "@/effect/instance-context"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Permission } from "@/permission/service"
+import { Filesystem } from "@/util/filesystem"
+import { Config } from "../config/config"
+import { ConfigMarkdown } from "../config/markdown"
+import { Glob } from "../util/glob"
+import { Log } from "../util/log"
+import { Discovery } from "./discovery"
+
+export namespace Skill {
+  const log = Log.create({ service: "skill" })
+  const EXTERNAL_DIRS = [".claude", ".agents"]
+  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+  const SKILL_PATTERN = "**/SKILL.md"
+
+  export const Info = z.object({
+    name: z.string(),
+    description: z.string(),
+    location: z.string(),
+    content: z.string(),
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const InvalidError = NamedError.create(
+    "SkillInvalidError",
+    z.object({
+      path: z.string(),
+      message: z.string().optional(),
+      issues: z.custom<z.core.$ZodIssue[]>().optional(),
+    }),
+  )
+
+  export const NameMismatchError = NamedError.create(
+    "SkillNameMismatchError",
+    z.object({
+      path: z.string(),
+      expected: z.string(),
+      actual: z.string(),
+    }),
+  )
+
+  type State = {
+    skills: Record<string, Info>
+    dirs: Set<string>
+    task?: Promise<void>
+  }
+
+  type Cache = State & {
+    ensure: () => Promise<void>
+  }
+
+  export interface Interface {
+    readonly get: (name: string) => Effect.Effect<Info | undefined>
+    readonly all: () => Effect.Effect<Info[]>
+    readonly dirs: () => Effect.Effect<string[]>
+    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
+  }
+
+  const add = async (state: State, match: string) => {
+    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
+      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+        ? err.data.message
+        : `Failed to parse skill ${match}`
+      const { Session } = await import("@/session")
+      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+      log.error("failed to load skill", { skill: match, err })
+      return undefined
+    })
+
+    if (!md) return
+
+    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+    if (!parsed.success) return
+
+    if (state.skills[parsed.data.name]) {
+      log.warn("duplicate skill name", {
+        name: parsed.data.name,
+        existing: state.skills[parsed.data.name].location,
+        duplicate: match,
+      })
+    }
+
+    state.dirs.add(path.dirname(match))
+    state.skills[parsed.data.name] = {
+      name: parsed.data.name,
+      description: parsed.data.description,
+      location: match,
+      content: md.content,
+    }
+  }
+
+  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
+    return Glob.scan(pattern, {
+      cwd: root,
+      absolute: true,
+      include: "file",
+      symlink: true,
+      dot: opts?.dot,
+    })
+      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
+      .catch((error) => {
+        if (!opts?.scope) throw error
+        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
+      })
+  }
+
+  // TODO: Migrate to Effect
+  const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
+    const state: State = {
+      skills: {},
+      dirs: new Set<string>(),
+    }
+
+    const load = async () => {
+      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+        for (const dir of EXTERNAL_DIRS) {
+          const root = path.join(Global.Path.home, dir)
+          if (!(await Filesystem.isDir(root))) continue
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+        }
+
+        for await (const root of Filesystem.up({
+          targets: EXTERNAL_DIRS,
+          start: instance.directory,
+          stop: instance.project.worktree,
+        })) {
+          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+        }
+      }
+
+      for (const dir of await Config.directories()) {
+        await scan(state, dir, OPENCODE_SKILL_PATTERN)
+      }
+
+      const cfg = await Config.get()
+      for (const item of cfg.skills?.paths ?? []) {
+        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+        const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
+        if (!(await Filesystem.isDir(dir))) {
+          log.warn("skill path not found", { path: dir })
+          continue
+        }
+
+        await scan(state, dir, SKILL_PATTERN)
+      }
+
+      for (const url of cfg.skills?.urls ?? []) {
+        for (const dir of await Effect.runPromise(discovery.pull(url))) {
+          state.dirs.add(dir)
+          await scan(state, dir, SKILL_PATTERN)
+        }
+      }
+
+      log.info("init", { count: Object.keys(state.skills).length })
+    }
+
+    const ensure = () => {
+      if (state.task) return state.task
+      state.task = load().catch((err) => {
+        state.task = undefined
+        throw err
+      })
+      return state.task
+    }
+
+    return { ...state, ensure }
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
+
+  export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const instance = yield* InstanceContext
+      const discovery = yield* Discovery.Service
+      const state = create(instance, discovery)
+
+      const get = Effect.fn("Skill.get")(function* (name: string) {
+        yield* Effect.promise(() => state.ensure())
+        return state.skills[name]
+      })
+
+      const all = Effect.fn("Skill.all")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Object.values(state.skills)
+      })
+
+      const dirs = Effect.fn("Skill.dirs")(function* () {
+        yield* Effect.promise(() => state.ensure())
+        return Array.from(state.dirs)
+      })
+
+      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+        yield* Effect.promise(() => state.ensure())
+        const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+        if (!agent) return list
+        return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
+      })
+
+      return Service.of({ get, all, dirs, available })
+    }),
+  ).pipe(Layer.fresh)
+
+  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
+    Layer.provide(Discovery.defaultLayer),
+  )
+
+  export function fmt(list: Info[], opts: { verbose: boolean }) {
+    if (list.length === 0) return "No skills are currently available."
+
+    if (opts.verbose) {
+      return [
+        "<available_skills>",
+        ...list.flatMap((skill) => [
+          "  <skill>",
+          `    <name>${skill.name}</name>`,
+          `    <description>${skill.description}</description>`,
+          `    <location>${pathToFileURL(skill.location).href}</location>`,
+          "  </skill>",
+        ]),
+        "</available_skills>",
+      ].join("\n")
+    }
+
+    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+  }
+}

+ 15 - 235
packages/opencode/src/skill/skill.ts

@@ -1,255 +1,35 @@
-import os from "os"
-import path from "path"
-import { pathToFileURL } from "url"
-import z from "zod"
-import { Effect, Layer, ServiceMap } from "effect"
-import { NamedError } from "@opencode-ai/util/error"
-import type { Agent } from "@/agent/agent"
-import { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
 import { runPromiseInstance } from "@/effect/runtime"
-import { Flag } from "@/flag/flag"
-import { Global } from "@/global"
-import { PermissionNext } from "@/permission"
-import { Filesystem } from "@/util/filesystem"
-import { Config } from "../config/config"
-import { ConfigMarkdown } from "../config/markdown"
-import { Glob } from "../util/glob"
-import { Log } from "../util/log"
-import { Discovery } from "./discovery"
+import type { Agent } from "@/agent/agent"
+import { Skill as S } from "./service"
 
 export namespace Skill {
-  const log = Log.create({ service: "skill" })
-  const EXTERNAL_DIRS = [".claude", ".agents"]
-  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
-  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
-  const SKILL_PATTERN = "**/SKILL.md"
-
-  export const Info = z.object({
-    name: z.string(),
-    description: z.string(),
-    location: z.string(),
-    content: z.string(),
-  })
-  export type Info = z.infer<typeof Info>
-
-  export const InvalidError = NamedError.create(
-    "SkillInvalidError",
-    z.object({
-      path: z.string(),
-      message: z.string().optional(),
-      issues: z.custom<z.core.$ZodIssue[]>().optional(),
-    }),
-  )
-
-  export const NameMismatchError = NamedError.create(
-    "SkillNameMismatchError",
-    z.object({
-      path: z.string(),
-      expected: z.string(),
-      actual: z.string(),
-    }),
-  )
-
-  type State = {
-    skills: Record<string, Info>
-    dirs: Set<string>
-    task?: Promise<void>
-  }
-
-  type Cache = State & {
-    ensure: () => Promise<void>
-  }
-
-  export interface Interface {
-    readonly get: (name: string) => Effect.Effect<Info | undefined>
-    readonly all: () => Effect.Effect<Info[]>
-    readonly dirs: () => Effect.Effect<string[]>
-    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
-  }
-
-  const add = async (state: State, match: string) => {
-    const md = await ConfigMarkdown.parse(match).catch(async (err) => {
-      const message = ConfigMarkdown.FrontmatterError.isInstance(err)
-        ? err.data.message
-        : `Failed to parse skill ${match}`
-      const { Session } = await import("@/session")
-      Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
-      log.error("failed to load skill", { skill: match, err })
-      return undefined
-    })
-
-    if (!md) return
-
-    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
-    if (!parsed.success) return
-
-    if (state.skills[parsed.data.name]) {
-      log.warn("duplicate skill name", {
-        name: parsed.data.name,
-        existing: state.skills[parsed.data.name].location,
-        duplicate: match,
-      })
-    }
-
-    state.dirs.add(path.dirname(match))
-    state.skills[parsed.data.name] = {
-      name: parsed.data.name,
-      description: parsed.data.description,
-      location: match,
-      content: md.content,
-    }
-  }
-
-  const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
-    return Glob.scan(pattern, {
-      cwd: root,
-      absolute: true,
-      include: "file",
-      symlink: true,
-      dot: opts?.dot,
-    })
-      .then((matches) => Promise.all(matches.map((match) => add(state, match))))
-      .catch((error) => {
-        if (!opts?.scope) throw error
-        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
-      })
-  }
+  export const Info = S.Info
+  export type Info = S.Info
 
-  // TODO: Migrate to Effect
-  const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
-    const state: State = {
-      skills: {},
-      dirs: new Set<string>(),
-    }
+  export const InvalidError = S.InvalidError
+  export const NameMismatchError = S.NameMismatchError
 
-    const load = async () => {
-      if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
-        for (const dir of EXTERNAL_DIRS) {
-          const root = path.join(Global.Path.home, dir)
-          if (!(await Filesystem.isDir(root))) continue
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
-        }
+  export type Interface = S.Interface
 
-        for await (const root of Filesystem.up({
-          targets: EXTERNAL_DIRS,
-          start: instance.directory,
-          stop: instance.project.worktree,
-        })) {
-          await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
-        }
-      }
+  export const Service = S.Service
+  export const layer = S.layer
+  export const defaultLayer = S.defaultLayer
 
-      for (const dir of await Config.directories()) {
-        await scan(state, dir, OPENCODE_SKILL_PATTERN)
-      }
-
-      const cfg = await Config.get()
-      for (const item of cfg.skills?.paths ?? []) {
-        const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
-        const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
-        if (!(await Filesystem.isDir(dir))) {
-          log.warn("skill path not found", { path: dir })
-          continue
-        }
-
-        await scan(state, dir, SKILL_PATTERN)
-      }
-
-      for (const url of cfg.skills?.urls ?? []) {
-        for (const dir of await Effect.runPromise(discovery.pull(url))) {
-          state.dirs.add(dir)
-          await scan(state, dir, SKILL_PATTERN)
-        }
-      }
-
-      log.info("init", { count: Object.keys(state.skills).length })
-    }
-
-    const ensure = () => {
-      if (state.task) return state.task
-      state.task = load().catch((err) => {
-        state.task = undefined
-        throw err
-      })
-      return state.task
-    }
-
-    return { ...state, ensure }
-  }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
-
-  export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const instance = yield* InstanceContext
-      const discovery = yield* Discovery.Service
-      const state = create(instance, discovery)
-
-      const get = Effect.fn("Skill.get")(function* (name: string) {
-        yield* Effect.promise(() => state.ensure())
-        return state.skills[name]
-      })
-
-      const all = Effect.fn("Skill.all")(function* () {
-        yield* Effect.promise(() => state.ensure())
-        return Object.values(state.skills)
-      })
-
-      const dirs = Effect.fn("Skill.dirs")(function* () {
-        yield* Effect.promise(() => state.ensure())
-        return Array.from(state.dirs)
-      })
-
-      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
-        yield* Effect.promise(() => state.ensure())
-        const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
-        if (!agent) return list
-        return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
-      })
-
-      return Service.of({ get, all, dirs, available })
-    }),
-  )
-
-  export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
-    Layer.provide(Discovery.defaultLayer),
-  )
+  export const fmt = S.fmt
 
   export async function get(name: string) {
-    return runPromiseInstance(Service.use((skill) => skill.get(name)))
+    return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
   }
 
   export async function all() {
-    return runPromiseInstance(Service.use((skill) => skill.all()))
+    return runPromiseInstance(S.Service.use((skill) => skill.all()))
   }
 
   export async function dirs() {
-    return runPromiseInstance(Service.use((skill) => skill.dirs()))
+    return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
   }
 
   export async function available(agent?: Agent.Info) {
-    return runPromiseInstance(Service.use((skill) => skill.available(agent)))
-  }
-
-  export function fmt(list: Info[], opts: { verbose: boolean }) {
-    if (list.length === 0) return "No skills are currently available."
-
-    if (opts.verbose) {
-      return [
-        "<available_skills>",
-        ...list.flatMap((skill) => [
-          "  <skill>",
-          `    <name>${skill.name}</name>`,
-          `    <description>${skill.description}</description>`,
-          `    <location>${pathToFileURL(skill.location).href}</location>`,
-          "  </skill>",
-        ]),
-        "</available_skills>",
-      ].join("\n")
-    }
-
-    return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+    return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
   }
 }

+ 18 - 323
packages/opencode/src/snapshot/index.ts

@@ -1,349 +1,44 @@
-import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import path from "path"
-import z from "zod"
-import { InstanceContext } from "@/effect/instance-context"
 import { runPromiseInstance } from "@/effect/runtime"
-import { AppFileSystem } from "@/filesystem"
-import { Config } from "../config/config"
-import { Global } from "../global"
-import { Log } from "../util/log"
+import { Snapshot as S } from "./service"
 
 export namespace Snapshot {
-  export const Patch = z.object({
-    hash: z.string(),
-    files: z.string().array(),
-  })
-  export type Patch = z.infer<typeof Patch>
+  export const Patch = S.Patch
+  export type Patch = S.Patch
 
-  export const FileDiff = z
-    .object({
-      file: z.string(),
-      before: z.string(),
-      after: z.string(),
-      additions: z.number(),
-      deletions: z.number(),
-      status: z.enum(["added", "deleted", "modified"]).optional(),
-    })
-    .meta({
-      ref: "FileDiff",
-    })
-  export type FileDiff = z.infer<typeof FileDiff>
+  export const FileDiff = S.FileDiff
+  export type FileDiff = S.FileDiff
+
+  export type Interface = S.Interface
+
+  export const Service = S.Service
+  export const layer = S.layer
+  export const defaultLayer = S.defaultLayer
 
   export async function cleanup() {
-    return runPromiseInstance(Service.use((svc) => svc.cleanup()))
+    return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
   }
 
   export async function track() {
-    return runPromiseInstance(Service.use((svc) => svc.track()))
+    return runPromiseInstance(S.Service.use((svc) => svc.track()))
   }
 
   export async function patch(hash: string) {
-    return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
+    return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
   }
 
   export async function restore(snapshot: string) {
-    return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
+    return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
   }
 
   export async function revert(patches: Patch[]) {
-    return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
+    return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
   }
 
   export async function diff(hash: string) {
-    return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
+    return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
   }
 
   export async function diffFull(from: string, to: string) {
-    return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
-  }
-
-  const log = Log.create({ service: "snapshot" })
-  const prune = "7.days"
-  const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
-  const cfg = ["-c", "core.autocrlf=false", ...core]
-  const quote = [...cfg, "-c", "core.quotepath=false"]
-
-  interface GitResult {
-    readonly code: ChildProcessSpawner.ExitCode
-    readonly text: string
-    readonly stderr: string
-  }
-
-  export interface Interface {
-    readonly cleanup: () => Effect.Effect<void>
-    readonly track: () => Effect.Effect<string | undefined>
-    readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
-    readonly restore: (snapshot: string) => Effect.Effect<void>
-    readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
-    readonly diff: (hash: string) => Effect.Effect<string>
-    readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
+    return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
   }
-
-  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
-
-  export const layer: Layer.Layer<
-    Service,
-    never,
-    InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
-  > = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const ctx = yield* InstanceContext
-      const fs = yield* AppFileSystem.Service
-      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-      const directory = ctx.directory
-      const worktree = ctx.worktree
-      const project = ctx.project
-      const gitdir = path.join(Global.Path.data, "snapshot", project.id)
-
-      const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
-
-      const git = Effect.fnUntraced(
-        function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
-          const proc = ChildProcess.make("git", cmd, {
-            cwd: opts?.cwd,
-            env: opts?.env,
-            extendEnv: true,
-          })
-          const handle = yield* spawner.spawn(proc)
-          const [text, stderr] = yield* Effect.all(
-            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
-            { concurrency: 2 },
-          )
-          const code = yield* handle.exitCode
-          return { code, text, stderr } satisfies GitResult
-        },
-        Effect.scoped,
-        Effect.catch((err) =>
-          Effect.succeed({
-            code: ChildProcessSpawner.ExitCode(1),
-            text: "",
-            stderr: String(err),
-          }),
-        ),
-      )
-
-      // Snapshot-specific error handling on top of AppFileSystem
-      const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
-      const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
-      const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
-
-      const enabled = Effect.fnUntraced(function* () {
-        if (project.vcs !== "git") return false
-        return (yield* Effect.promise(() => Config.get())).snapshot !== false
-      })
-
-      const excludes = Effect.fnUntraced(function* () {
-        const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
-          cwd: worktree,
-        })
-        const file = result.text.trim()
-        if (!file) return
-        if (!(yield* exists(file))) return
-        return file
-      })
-
-      const sync = Effect.fnUntraced(function* () {
-        const file = yield* excludes()
-        const target = path.join(gitdir, "info", "exclude")
-        yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
-        if (!file) {
-          yield* fs.writeFileString(target, "").pipe(Effect.orDie)
-          return
-        }
-        yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
-      })
-
-      const add = Effect.fnUntraced(function* () {
-        yield* sync()
-        yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
-      })
-
-      const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
-        if (!(yield* enabled())) return
-        if (!(yield* exists(gitdir))) return
-        const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
-        if (result.code !== 0) {
-          log.warn("cleanup failed", {
-            exitCode: result.code,
-            stderr: result.stderr,
-          })
-          return
-        }
-        log.info("cleanup", { prune })
-      })
-
-      const track = Effect.fn("Snapshot.track")(function* () {
-        if (!(yield* enabled())) return
-        const existed = yield* exists(gitdir)
-        yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
-        if (!existed) {
-          yield* git(["init"], {
-            env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
-          })
-          yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
-          yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
-          yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
-          yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
-          log.info("initialized")
-        }
-        yield* add()
-        const result = yield* git(args(["write-tree"]), { cwd: directory })
-        const hash = result.text.trim()
-        log.info("tracking", { hash, cwd: directory, git: gitdir })
-        return hash
-      })
-
-      const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
-        yield* add()
-        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
-          cwd: directory,
-        })
-        if (result.code !== 0) {
-          log.warn("failed to get diff", { hash, exitCode: result.code })
-          return { hash, files: [] }
-        }
-        return {
-          hash,
-          files: result.text
-            .trim()
-            .split("\n")
-            .map((x) => x.trim())
-            .filter(Boolean)
-            .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
-        }
-      })
-
-      const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
-        log.info("restore", { commit: snapshot })
-        const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
-        if (result.code === 0) {
-          const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
-          if (checkout.code === 0) return
-          log.error("failed to restore snapshot", {
-            snapshot,
-            exitCode: checkout.code,
-            stderr: checkout.stderr,
-          })
-          return
-        }
-        log.error("failed to restore snapshot", {
-          snapshot,
-          exitCode: result.code,
-          stderr: result.stderr,
-        })
-      })
-
-      const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
-        const seen = new Set<string>()
-        for (const item of patches) {
-          for (const file of item.files) {
-            if (seen.has(file)) continue
-            seen.add(file)
-            log.info("reverting", { file, hash: item.hash })
-            const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
-            if (result.code !== 0) {
-              const rel = path.relative(worktree, file)
-              const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
-              if (tree.code === 0 && tree.text.trim()) {
-                log.info("file existed in snapshot but checkout failed, keeping", { file })
-              } else {
-                log.info("file did not exist in snapshot, deleting", { file })
-                yield* remove(file)
-              }
-            }
-          }
-        }
-      })
-
-      const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
-        yield* add()
-        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
-          cwd: worktree,
-        })
-        if (result.code !== 0) {
-          log.warn("failed to get diff", {
-            hash,
-            exitCode: result.code,
-            stderr: result.stderr,
-          })
-          return ""
-        }
-        return result.text.trim()
-      })
-
-      const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
-        const result: Snapshot.FileDiff[] = []
-        const status = new Map<string, "added" | "deleted" | "modified">()
-
-        const statuses = yield* git(
-          [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
-          { cwd: directory },
-        )
-
-        for (const line of statuses.text.trim().split("\n")) {
-          if (!line) continue
-          const [code, file] = line.split("\t")
-          if (!code || !file) continue
-          status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-        }
-
-        const numstat = yield* git(
-          [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-          {
-            cwd: directory,
-          },
-        )
-
-        for (const line of numstat.text.trim().split("\n")) {
-          if (!line) continue
-          const [adds, dels, file] = line.split("\t")
-          if (!file) continue
-          const binary = adds === "-" && dels === "-"
-          const [before, after] = binary
-            ? ["", ""]
-            : yield* Effect.all(
-                [
-                  git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                  git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
-                ],
-                { concurrency: 2 },
-              )
-          const additions = binary ? 0 : parseInt(adds)
-          const deletions = binary ? 0 : parseInt(dels)
-          result.push({
-            file,
-            before,
-            after,
-            additions: Number.isFinite(additions) ? additions : 0,
-            deletions: Number.isFinite(deletions) ? deletions : 0,
-            status: status.get(file) ?? "modified",
-          })
-        }
-
-        return result
-      })
-
-      yield* cleanup().pipe(
-        Effect.catchCause((cause) => {
-          log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
-          return Effect.void
-        }),
-        Effect.repeat(Schedule.spaced(Duration.hours(1))),
-        Effect.delay(Duration.minutes(1)),
-        Effect.forkScoped,
-      )
-
-      return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(
-    Layer.provide(NodeChildProcessSpawner.layer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
-    Layer.provide(NodePath.layer),
-  )
 }

+ 320 - 0
packages/opencode/src/snapshot/service.ts

@@ -0,0 +1,320 @@
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import path from "path"
+import z from "zod"
+import { InstanceContext } from "@/effect/instance-context"
+import { AppFileSystem } from "@/filesystem"
+import { Config } from "../config/config"
+import { Global } from "../global"
+import { Log } from "../util/log"
+
+export namespace Snapshot {
+  export const Patch = z.object({
+    hash: z.string(),
+    files: z.string().array(),
+  })
+  export type Patch = z.infer<typeof Patch>
+
+  export const FileDiff = z
+    .object({
+      file: z.string(),
+      before: z.string(),
+      after: z.string(),
+      additions: z.number(),
+      deletions: z.number(),
+      status: z.enum(["added", "deleted", "modified"]).optional(),
+    })
+    .meta({
+      ref: "FileDiff",
+    })
+  export type FileDiff = z.infer<typeof FileDiff>
+
+  const log = Log.create({ service: "snapshot" })
+  const prune = "7.days"
+  const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
+  const cfg = ["-c", "core.autocrlf=false", ...core]
+  const quote = [...cfg, "-c", "core.quotepath=false"]
+
+  interface GitResult {
+    readonly code: ChildProcessSpawner.ExitCode
+    readonly text: string
+    readonly stderr: string
+  }
+
+  export interface Interface {
+    readonly cleanup: () => Effect.Effect<void>
+    readonly track: () => Effect.Effect<string | undefined>
+    readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
+    readonly restore: (snapshot: string) => Effect.Effect<void>
+    readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
+    readonly diff: (hash: string) => Effect.Effect<string>
+    readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const ctx = yield* InstanceContext
+      const fs = yield* AppFileSystem.Service
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const directory = ctx.directory
+      const worktree = ctx.worktree
+      const project = ctx.project
+      const gitdir = path.join(Global.Path.data, "snapshot", project.id)
+
+      const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
+
+      const git = Effect.fnUntraced(
+        function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+          const proc = ChildProcess.make("git", cmd, {
+            cwd: opts?.cwd,
+            env: opts?.env,
+            extendEnv: true,
+          })
+          const handle = yield* spawner.spawn(proc)
+          const [text, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          const code = yield* handle.exitCode
+          return { code, text, stderr } satisfies GitResult
+        },
+        Effect.scoped,
+        Effect.catch((err) =>
+          Effect.succeed({
+            code: ChildProcessSpawner.ExitCode(1),
+            text: "",
+            stderr: String(err),
+          }),
+        ),
+      )
+
+      // Snapshot-specific error handling on top of AppFileSystem
+      const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+      const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+      const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+
+      const enabled = Effect.fnUntraced(function* () {
+        if (project.vcs !== "git") return false
+        return (yield* Effect.promise(() => Config.get())).snapshot !== false
+      })
+
+      const excludes = Effect.fnUntraced(function* () {
+        const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+          cwd: worktree,
+        })
+        const file = result.text.trim()
+        if (!file) return
+        if (!(yield* exists(file))) return
+        return file
+      })
+
+      const sync = Effect.fnUntraced(function* () {
+        const file = yield* excludes()
+        const target = path.join(gitdir, "info", "exclude")
+        yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
+        if (!file) {
+          yield* fs.writeFileString(target, "").pipe(Effect.orDie)
+          return
+        }
+        yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+      })
+
+      const add = Effect.fnUntraced(function* () {
+        yield* sync()
+        yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
+      })
+
+      const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
+        if (!(yield* enabled())) return
+        if (!(yield* exists(gitdir))) return
+        const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
+        if (result.code !== 0) {
+          log.warn("cleanup failed", {
+            exitCode: result.code,
+            stderr: result.stderr,
+          })
+          return
+        }
+        log.info("cleanup", { prune })
+      })
+
+      const track = Effect.fn("Snapshot.track")(function* () {
+        if (!(yield* enabled())) return
+        const existed = yield* exists(gitdir)
+        yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
+        if (!existed) {
+          yield* git(["init"], {
+            env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
+          })
+          yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
+          yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
+          yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
+          log.info("initialized")
+        }
+        yield* add()
+        const result = yield* git(args(["write-tree"]), { cwd: directory })
+        const hash = result.text.trim()
+        log.info("tracking", { hash, cwd: directory, git: gitdir })
+        return hash
+      })
+
+      const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
+          cwd: directory,
+        })
+        if (result.code !== 0) {
+          log.warn("failed to get diff", { hash, exitCode: result.code })
+          return { hash, files: [] }
+        }
+        return {
+          hash,
+          files: result.text
+            .trim()
+            .split("\n")
+            .map((x) => x.trim())
+            .filter(Boolean)
+            .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
+        }
+      })
+
+      const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+        log.info("restore", { commit: snapshot })
+        const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
+        if (result.code === 0) {
+          const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
+          if (checkout.code === 0) return
+          log.error("failed to restore snapshot", {
+            snapshot,
+            exitCode: checkout.code,
+            stderr: checkout.stderr,
+          })
+          return
+        }
+        log.error("failed to restore snapshot", {
+          snapshot,
+          exitCode: result.code,
+          stderr: result.stderr,
+        })
+      })
+
+      const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+        const seen = new Set<string>()
+        for (const item of patches) {
+          for (const file of item.files) {
+            if (seen.has(file)) continue
+            seen.add(file)
+            log.info("reverting", { file, hash: item.hash })
+            const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
+            if (result.code !== 0) {
+              const rel = path.relative(worktree, file)
+              const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
+              if (tree.code === 0 && tree.text.trim()) {
+                log.info("file existed in snapshot but checkout failed, keeping", { file })
+              } else {
+                log.info("file did not exist in snapshot, deleting", { file })
+                yield* remove(file)
+              }
+            }
+          }
+        }
+      })
+
+      const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
+        yield* add()
+        const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
+          cwd: worktree,
+        })
+        if (result.code !== 0) {
+          log.warn("failed to get diff", {
+            hash,
+            exitCode: result.code,
+            stderr: result.stderr,
+          })
+          return ""
+        }
+        return result.text.trim()
+      })
+
+      const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+        const result: Snapshot.FileDiff[] = []
+        const status = new Map<string, "added" | "deleted" | "modified">()
+
+        const statuses = yield* git(
+          [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+          { cwd: directory },
+        )
+
+        for (const line of statuses.text.trim().split("\n")) {
+          if (!line) continue
+          const [code, file] = line.split("\t")
+          if (!code || !file) continue
+          status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+        }
+
+        const numstat = yield* git(
+          [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+          {
+            cwd: directory,
+          },
+        )
+
+        for (const line of numstat.text.trim().split("\n")) {
+          if (!line) continue
+          const [adds, dels, file] = line.split("\t")
+          if (!file) continue
+          const binary = adds === "-" && dels === "-"
+          const [before, after] = binary
+            ? ["", ""]
+            : yield* Effect.all(
+                [
+                  git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                  git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+                ],
+                { concurrency: 2 },
+              )
+          const additions = binary ? 0 : parseInt(adds)
+          const deletions = binary ? 0 : parseInt(dels)
+          result.push({
+            file,
+            before,
+            after,
+            additions: Number.isFinite(additions) ? additions : 0,
+            deletions: Number.isFinite(deletions) ? deletions : 0,
+            status: status.get(file) ?? "modified",
+          })
+        }
+
+        return result
+      })
+
+      yield* cleanup().pipe(
+        Effect.catchCause((cause) => {
+          log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
+          return Effect.void
+        }),
+        Effect.repeat(Schedule.spaced(Duration.hours(1))),
+        Effect.delay(Duration.minutes(1)),
+        Effect.forkScoped,
+      )
+
+      return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
+    }),
+  ).pipe(Layer.fresh)
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
+    Layer.provide(NodePath.layer),
+  )
+}

+ 83 - 53
packages/opencode/src/tool/registry.ts

@@ -1,38 +1,16 @@
-import { PlanExitTool } from "./plan"
-import { QuestionTool } from "./question"
-import { BashTool } from "./bash"
-import { EditTool } from "./edit"
-import { GlobTool } from "./glob"
-import { GrepTool } from "./grep"
-import { BatchTool } from "./batch"
-import { ReadTool } from "./read"
-import { TaskTool } from "./task"
-import { TodoWriteTool, TodoReadTool } from "./todo"
-import { WebFetchTool } from "./webfetch"
-import { WriteTool } from "./write"
-import { InvalidTool } from "./invalid"
-import { SkillTool } from "./skill"
 import type { Agent } from "../agent/agent"
 import { Tool } from "./tool"
-import { Config } from "../config/config"
 import path from "path"
 import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
 import z from "zod"
-import { Plugin } from "../plugin"
 import { ProviderID, type ModelID } from "../provider/schema"
-import { WebSearchTool } from "./websearch"
-import { CodeSearchTool } from "./codesearch"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
-import { LspTool } from "./lsp"
 import { Truncate } from "./truncate"
-
-import { ApplyPatchTool } from "./apply_patch"
 import { Glob } from "../util/glob"
 import { pathToFileURL } from "url"
-import { Effect, Fiber, Layer, ServiceMap } from "effect"
+import { Effect, Layer, ServiceMap } from "effect"
 import { InstanceContext } from "@/effect/instance-context"
-import { runPromiseInstance } from "@/effect/runtime"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -54,9 +32,11 @@ export namespace ToolRegistry {
       const instance = yield* InstanceContext
 
       const custom: Tool.Info[] = []
+      let task: Promise<void> | undefined
 
       const load = Effect.fn("ToolRegistry.load")(function* () {
         yield* Effect.promise(async () => {
+          const [{ Config }, { Plugin }] = await Promise.all([import("../config/config"), import("../plugin")])
           const matches = await Config.directories().then((dirs) =>
             dirs.flatMap((dir) =>
               Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
@@ -65,7 +45,7 @@ export namespace ToolRegistry {
           if (matches.length) await Config.waitForDependencies()
           for (const match of matches) {
             const namespace = path.basename(match, path.extname(match))
-            const mod = await import(pathToFileURL(match).href)
+            const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
             for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
               custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
             }
@@ -80,10 +60,14 @@ export namespace ToolRegistry {
         })
       })
 
-      const loadFiber = yield* load().pipe(
-        Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
-        Effect.forkScoped,
-      )
+      const ensure = Effect.fn("ToolRegistry.ensure")(function* () {
+        yield* Effect.promise(() => {
+          task ??= Effect.runPromise(
+            load().pipe(Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause })))),
+          )
+          return task
+        })
+      })
 
       function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
         return {
@@ -110,35 +94,75 @@ export namespace ToolRegistry {
       }
 
       async function all(): Promise<Tool.Info[]> {
+        const { Config } = await import("../config/config")
         const config = await Config.get()
         const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
+        const [
+          invalid,
+          questionMod,
+          bash,
+          read,
+          glob,
+          grep,
+          edit,
+          write,
+          task,
+          webfetch,
+          todo,
+          websearch,
+          codesearch,
+          skill,
+          applyPatch,
+          lsp,
+          batch,
+          plan,
+        ] = await Promise.all([
+          import("./invalid"),
+          import("./question"),
+          import("./bash"),
+          import("./read"),
+          import("./glob"),
+          import("./grep"),
+          import("./edit"),
+          import("./write"),
+          import("./task"),
+          import("./webfetch"),
+          import("./todo"),
+          import("./websearch"),
+          import("./codesearch"),
+          import("./skill"),
+          import("./apply_patch"),
+          import("./lsp"),
+          import("./batch"),
+          import("./plan"),
+        ])
 
         return [
-          InvalidTool,
-          ...(question ? [QuestionTool] : []),
-          BashTool,
-          ReadTool,
-          GlobTool,
-          GrepTool,
-          EditTool,
-          WriteTool,
-          TaskTool,
-          WebFetchTool,
-          TodoWriteTool,
+          invalid.InvalidTool,
+          ...(question ? [questionMod.QuestionTool] : []),
+          bash.BashTool,
+          read.ReadTool,
+          glob.GlobTool,
+          grep.GrepTool,
+          edit.EditTool,
+          write.WriteTool,
+          task.TaskTool,
+          webfetch.WebFetchTool,
+          todo.TodoWriteTool,
           // TodoReadTool,
-          WebSearchTool,
-          CodeSearchTool,
-          SkillTool,
-          ApplyPatchTool,
-          ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
-          ...(config.experimental?.batch_tool === true ? [BatchTool] : []),
-          ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
+          websearch.WebSearchTool,
+          codesearch.CodeSearchTool,
+          skill.SkillTool,
+          applyPatch.ApplyPatchTool,
+          ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp.LspTool] : []),
+          ...(config.experimental?.batch_tool === true ? [batch.BatchTool] : []),
+          ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan.PlanExitTool] : []),
           ...custom,
         ]
       }
 
       const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
-        yield* Fiber.join(loadFiber)
+        yield* ensure()
         const idx = custom.findIndex((t) => t.id === tool.id)
         if (idx >= 0) {
           custom.splice(idx, 1, tool)
@@ -148,7 +172,7 @@ export namespace ToolRegistry {
       })
 
       const ids = Effect.fn("ToolRegistry.ids")(function* () {
-        yield* Fiber.join(loadFiber)
+        yield* ensure()
         const tools = yield* Effect.promise(() => all())
         return tools.map((t) => t.id)
       })
@@ -157,7 +181,7 @@ export namespace ToolRegistry {
         model: { providerID: ProviderID; modelID: ModelID },
         agent?: Agent.Info,
       ) {
-        yield* Fiber.join(loadFiber)
+        yield* ensure()
         const allTools = yield* Effect.promise(() => all())
         return yield* Effect.promise(() =>
           Promise.all(
@@ -183,6 +207,7 @@ export namespace ToolRegistry {
                   description: tool.description,
                   parameters: tool.parameters,
                 }
+                const { Plugin } = await import("../plugin")
                 await Plugin.trigger("tool.definition", { toolID: t.id }, output)
                 return {
                   id: t.id,
@@ -197,14 +222,19 @@ export namespace ToolRegistry {
 
       return Service.of({ register, ids, tools })
     }),
-  )
+  ).pipe(Layer.fresh)
+
+  async function run<A, E>(effect: Effect.Effect<A, E, Service>) {
+    const { runPromiseInstance } = await import("@/effect/runtime")
+    return runPromiseInstance(effect)
+  }
 
   export async function register(tool: Tool.Info) {
-    return runPromiseInstance(Service.use((svc) => svc.register(tool)))
+    return run(Service.use((svc) => svc.register(tool)))
   }
 
   export async function ids() {
-    return runPromiseInstance(Service.use((svc) => svc.ids()))
+    return run(Service.use((svc) => svc.ids()))
   }
 
   export async function tools(
@@ -214,6 +244,6 @@ export namespace ToolRegistry {
     },
     agent?: Agent.Info,
   ) {
-    return runPromiseInstance(Service.use((svc) => svc.tools(model, agent)))
+    return run(Service.use((svc) => svc.tools(model, agent)))
   }
 }

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

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

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

@@ -1,6 +1,6 @@
 import type { Agent } from "../agent/agent"
 import { runtime } from "@/effect/runtime"
-import { TruncateEffect as S } from "./truncate-effect"
+import { Truncate as S } from "./truncate-effect"
 
 export namespace Truncate {
   export const MAX_LINES = S.MAX_LINES

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

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

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

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

+ 7 - 9
packages/opencode/test/account/service.test.ts

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 
 import { AccountRepo } from "../../src/account/repo"
-import { AccountEffect } from "../../src/account/effect"
+import { Account } from "../../src/account/effect"
 import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
 import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
 const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
 
 const live = (client: HttpClient.HttpClient) =>
-  AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+  Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
 
 const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
   HttpClientResponse.fromWeb(
@@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
   )
 
 const poll = (body: unknown, status = 400) =>
-  AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
+  Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
 
 it.effect("orgsByAccount groups orgs per account", () =>
   Effect.gen(function* () {
@@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
       }),
     )
 
-    const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+    const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
 
     expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
       [AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () =>
       ),
     )
 
-    const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+    const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(token)).toBeDefined()
     expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -178,9 +178,7 @@ it.effect("config sends the selected org header", () =>
       }),
     )
 
-    const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
-      Effect.provide(live(client)),
-    )
+    const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
     expect(seen).toEqual({
@@ -209,7 +207,7 @@ it.effect("poll stores the account and first org on success", () =>
       ),
     )
 
-    const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
+    const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
 
     expect(res._tag).toBe("PollSuccess")
     if (res._tag === "PollSuccess") {

+ 128 - 0
packages/opencode/test/effect/runtime.test.ts

@@ -0,0 +1,128 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { runtime, runPromiseInstance } from "../../src/effect/runtime"
+import { Auth } from "../../src/auth/effect"
+import { Instances } from "../../src/effect/instances"
+import { Instance } from "../../src/project/instance"
+import { ProviderAuth } from "../../src/provider/auth"
+import { Vcs } from "../../src/project/vcs"
+import { Question } from "../../src/question"
+import { tmpdir } from "../fixture/fixture"
+
+/**
+ * Integration tests for the Effect runtime and LayerMap-based instance system.
+ *
+ * Each instance service layer has `.pipe(Layer.fresh)` at its definition site
+ * so it is always rebuilt per directory, while shared dependencies are provided
+ * outside the fresh boundary and remain memoizable.
+ *
+ * These tests verify the invariants using object identity (===) on the real
+ * production services — not mock services or return-value checks.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
+
+describe("effect/runtime", () => {
+  afterEach(async () => {
+    await Instance.disposeAll()
+  })
+
+  test("global services are shared across directories", async () => {
+    await using one = await tmpdir({ git: true })
+    await using two = await tmpdir({ git: true })
+
+    // Auth is a global service — it should be the exact same object
+    // regardless of which directory we're in.
+    const authOne = await Instance.provide({
+      directory: one.path,
+      fn: () => grabGlobal(Auth.Service),
+    })
+
+    const authTwo = await Instance.provide({
+      directory: two.path,
+      fn: () => grabGlobal(Auth.Service),
+    })
+
+    expect(authOne).toBe(authTwo)
+  })
+
+  test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
+    await using one = await tmpdir({ git: true })
+    await using two = await tmpdir({ git: true })
+
+    // ProviderAuth depends on Auth via defaultLayer.
+    // The instance service itself should be different per directory,
+    // but the underlying Auth should be shared.
+    const paOne = await Instance.provide({
+      directory: one.path,
+      fn: () => grabInstance(ProviderAuth.Service),
+    })
+
+    const paTwo = await Instance.provide({
+      directory: two.path,
+      fn: () => grabInstance(ProviderAuth.Service),
+    })
+
+    // Different directories → different ProviderAuth instances.
+    expect(paOne).not.toBe(paTwo)
+
+    // But the global Auth is the same object in both.
+    const authOne = await Instance.provide({
+      directory: one.path,
+      fn: () => grabGlobal(Auth.Service),
+    })
+    const authTwo = await Instance.provide({
+      directory: two.path,
+      fn: () => grabGlobal(Auth.Service),
+    })
+    expect(authOne).toBe(authTwo)
+  })
+
+  test("instance services are shared within the same directory", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
+        expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
+      },
+    })
+  })
+
+  test("different directories get different service instances", async () => {
+    await using one = await tmpdir({ git: true })
+    await using two = await tmpdir({ git: true })
+
+    const vcsOne = await Instance.provide({
+      directory: one.path,
+      fn: () => grabInstance(Vcs.Service),
+    })
+
+    const vcsTwo = await Instance.provide({
+      directory: two.path,
+      fn: () => grabInstance(Vcs.Service),
+    })
+
+    expect(vcsOne).not.toBe(vcsTwo)
+  })
+
+  test("disposal rebuilds services with a new instance", async () => {
+    await using tmp = await tmpdir({ git: true })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const before = await grabInstance(Question.Service)
+
+        await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
+
+        const after = await grabInstance(Question.Service)
+        expect(after).not.toBe(before)
+      },
+    })
+  })
+})

+ 12 - 6
packages/opencode/test/file/watcher.test.ts

@@ -5,9 +5,9 @@ import path from "path"
 import { Deferred, Effect, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { watcherConfigLayer, withServices } from "../fixture/instance"
-import { Bus } from "../../src/bus"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
+import { GlobalBus } from "../../src/bus/global"
 
 // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
 const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -16,6 +16,7 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
 // Helpers
 // ---------------------------------------------------------------------------
 
+type BusUpdate = { directory?: string; payload: { type: string; properties: Record<string, unknown> } }
 type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
 
 /** Run `body` with a live FileWatcher service. */
@@ -35,16 +36,21 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
 function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
   let done = false
 
-  const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
+  function on(evt: BusUpdate) {
     if (done) return
-    if (!check(evt.properties)) return
-    hit(evt.properties)
-  })
+    if (evt.directory !== directory) return
+    if (evt.payload.type !== FileWatcher.Event.Updated.type) return
+    const props = evt.payload.properties as WatcherEvent
+    if (!check(props)) return
+    hit(props)
+  }
+
+  GlobalBus.on("event", on)
 
   return () => {
     if (done) return
     done = true
-    unsub()
+    GlobalBus.off("event", on)
   }
 }
 

+ 1 - 1
packages/opencode/test/fixture/instance.ts

@@ -34,7 +34,7 @@ export function withServices<S>(
           project: Instance.project,
         }),
       )
-      let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
+      let resolved: Layer.Layer<S> = layer.pipe(Layer.provide(ctx)) as any
       if (options?.provide) {
         for (const l of options.provide) {
           resolved = resolved.pipe(Layer.provide(l)) as any

+ 139 - 35
packages/opencode/test/installation/installation.test.ts

@@ -1,47 +1,151 @@
-import { afterEach, describe, expect, test } from "bun:test"
+import { describe, expect, test } from "bun:test"
+import { Effect, Layer, Stream } from "effect"
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { Installation } from "../../src/installation"
 
-const fetch0 = globalThis.fetch
+const encoder = new TextEncoder()
 
-afterEach(() => {
-  globalThis.fetch = fetch0
-})
+function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) {
+  const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))))
+  return Layer.succeed(HttpClient.HttpClient, client)
+}
 
-describe("installation", () => {
-  test("reads release version from GitHub releases", async () => {
-    globalThis.fetch = (async () =>
-      new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
-        status: 200,
-        headers: { "content-type": "application/json" },
-      })) as unknown as typeof fetch
-
-    expect(await Installation.latest("unknown")).toBe("1.2.3")
+function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
+  const spawner = ChildProcessSpawner.make((command) => {
+    const std = ChildProcess.isStandardCommand(command) ? command : undefined
+    const output = handler(std?.command ?? "", std?.args ?? [])
+    return Effect.succeed(
+      ChildProcessSpawner.makeHandle({
+        pid: ChildProcessSpawner.ProcessId(0),
+        exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
+        isRunning: Effect.succeed(false),
+        kill: () => Effect.void,
+        stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
+        stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
+        stderr: Stream.empty,
+        all: Stream.empty,
+        getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
+        getOutputFd: () => Stream.empty,
+      }),
+    )
   })
+  return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
+}
 
-  test("reads scoop manifest versions", async () => {
-    globalThis.fetch = (async () =>
-      new Response(JSON.stringify({ version: "2.3.4" }), {
-        status: 200,
-        headers: { "content-type": "application/json" },
-      })) as unknown as typeof fetch
-
-    expect(await Installation.latest("scoop")).toBe("2.3.4")
+function jsonResponse(body: unknown) {
+  return new Response(JSON.stringify(body), {
+    status: 200,
+    headers: { "content-type": "application/json" },
   })
+}
+
+function testLayer(
+  httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
+  spawnHandler?: (cmd: string, args: readonly string[]) => string,
+) {
+  return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
+}
+
+describe("installation", () => {
+  describe("latest", () => {
+    test("reads release version from GitHub releases", async () => {
+      const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("1.2.3")
+    })
+
+    test("strips v prefix from GitHub release tag", async () => {
+      const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("4.0.0-beta.1")
+    })
+
+    test("reads npm registry versions", async () => {
+      const layer = testLayer(
+        () => jsonResponse({ version: "1.5.0" }),
+        (cmd, args) => {
+          if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
+          return ""
+        },
+      )
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("1.5.0")
+    })
+
+    test("reads npm registry versions for bun method", async () => {
+      const layer = testLayer(
+        () => jsonResponse({ version: "1.6.0" }),
+        () => "",
+      )
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("1.6.0")
+    })
+
+    test("reads scoop manifest versions", async () => {
+      const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("2.3.4")
+    })
+
+    test("reads chocolatey feed versions", async () => {
+      const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("3.4.5")
+    })
+
+    test("reads brew formulae API versions", async () => {
+      const layer = testLayer(
+        () => jsonResponse({ versions: { stable: "2.0.0" } }),
+        (cmd, args) => {
+          // getBrewFormula: return core formula (no tap)
+          if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
+          if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
+          return ""
+        },
+      )
+
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("2.0.0")
+    })
 
-  test("reads chocolatey feed versions", async () => {
-    globalThis.fetch = (async () =>
-      new Response(
-        JSON.stringify({
-          d: {
-            results: [{ Version: "3.4.5" }],
-          },
-        }),
-        {
-          status: 200,
-          headers: { "content-type": "application/json" },
+    test("reads brew tap info JSON via CLI", async () => {
+      const brewInfoJson = JSON.stringify({
+        formulae: [{ versions: { stable: "2.1.0" } }],
+      })
+      const layer = testLayer(
+        () => jsonResponse({}), // HTTP not used for tap formula
+        (cmd, args) => {
+          if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
+          if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
+          return ""
         },
-      )) as unknown as typeof fetch
+      )
 
-    expect(await Installation.latest("choco")).toBe("3.4.5")
+      const result = await Effect.runPromise(
+        Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("2.1.0")
+    })
   })
 })

+ 121 - 2
packages/opencode/test/provider/gitlab-duo.test.ts

@@ -1,12 +1,13 @@
-import { test, expect } from "bun:test"
+import { test, expect, describe } from "bun:test"
 import path from "path"
 
-import { ProviderID } from "../../src/provider/schema"
+import { ProviderID, ModelID } from "../../src/provider/schema"
 import { tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Provider } from "../../src/provider/provider"
 import { Env } from "../../src/env"
 import { Global } from "../../src/global"
+import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
 
 test("GitLab Duo: loads provider with API key from environment", async () => {
   await using tmp = await tmpdir({
@@ -287,3 +288,121 @@ test("GitLab Duo: has multiple agentic chat models available", async () => {
     },
   })
 })
+
+describe("GitLab Duo: workflow model routing", () => {
+  test("duo-workflow-* model routes through workflowChat", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      init: async () => {
+        Env.set("GITLAB_TOKEN", "test-token")
+      },
+      fn: async () => {
+        const providers = await Provider.list()
+        const gitlab = providers[ProviderID.gitlab]
+        expect(gitlab).toBeDefined()
+        gitlab.models["duo-workflow-sonnet-4-6"] = {
+          id: ModelID.make("duo-workflow-sonnet-4-6"),
+          providerID: ProviderID.make("gitlab"),
+          name: "Agent Platform (Claude Sonnet 4.6)",
+          family: "",
+          api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
+          status: "active",
+          headers: {},
+          options: { workflowRef: "claude_sonnet_4_6" },
+          cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
+          limit: { context: 200000, output: 64000 },
+          capabilities: {
+            temperature: false,
+            reasoning: true,
+            attachment: true,
+            toolcall: true,
+            input: { text: true, audio: false, image: true, video: false, pdf: true },
+            output: { text: true, audio: false, image: false, video: false, pdf: false },
+            interleaved: false,
+          },
+          release_date: "",
+          variants: {},
+        }
+        const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
+        expect(model).toBeDefined()
+        expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
+        const language = await Provider.getLanguage(model)
+        expect(language).toBeDefined()
+        expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
+      },
+    })
+  })
+
+  test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      init: async () => {
+        Env.set("GITLAB_TOKEN", "test-token")
+      },
+      fn: async () => {
+        const providers = await Provider.list()
+        expect(providers[ProviderID.gitlab]).toBeDefined()
+        const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
+        expect(model).toBeDefined()
+        const language = await Provider.getLanguage(model)
+        expect(language).toBeDefined()
+        expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
+      },
+    })
+  })
+
+  test("model.options merged with provider.options in getLanguage", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      init: async () => {
+        Env.set("GITLAB_TOKEN", "test-token")
+      },
+      fn: async () => {
+        const providers = await Provider.list()
+        const gitlab = providers[ProviderID.gitlab]
+        expect(gitlab.options?.featureFlags).toBeDefined()
+        const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
+        expect(model).toBeDefined()
+        expect(model.options).toBeDefined()
+      },
+    })
+  })
+})
+
+describe("GitLab Duo: static models", () => {
+  test("static duo-chat models always present regardless of discovery", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      init: async () => {
+        Env.set("GITLAB_TOKEN", "test-token")
+      },
+      fn: async () => {
+        const providers = await Provider.list()
+        const models = Object.keys(providers[ProviderID.gitlab].models)
+        expect(models).toContain("duo-chat-haiku-4-5")
+        expect(models).toContain("duo-chat-sonnet-4-5")
+        expect(models).toContain("duo-chat-opus-4-5")
+      },
+    })
+  })
+})

+ 1 - 1
packages/opencode/test/tool/fixtures/models-api.json

@@ -32933,7 +32933,7 @@
   "gitlab": {
     "id": "gitlab",
     "env": ["GITLAB_TOKEN"],
-    "npm": "@gitlab/gitlab-ai-provider",
+    "npm": "gitlab-ai-provider",
     "name": "GitLab Duo",
     "doc": "https://docs.gitlab.com/user/duo_agent_platform/",
     "models": {

+ 13 - 3
packages/opencode/test/tool/truncation.test.ts

@@ -2,14 +2,16 @@ import { describe, test, expect } from "bun:test"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Effect, FileSystem, Layer } from "effect"
 import { Truncate } from "../../src/tool/truncate"
-import { TruncateEffect } from "../../src/tool/truncate-effect"
+import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
 import { Identifier } from "../../src/id/id"
+import { Process } from "../../src/util/process"
 import { Filesystem } from "../../src/util/filesystem"
 import path from "path"
 import { testEffect } from "../lib/effect"
 import { writeFileStringScoped } from "../lib/filesystem"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+const ROOT = path.resolve(import.meta.dir, "..", "..")
 
 describe("Truncate", () => {
   describe("output", () => {
@@ -125,11 +127,19 @@ describe("Truncate", () => {
       if (result.truncated) throw new Error("expected not truncated")
       expect("outputPath" in result).toBe(false)
     })
+
+    test("loads truncate effect in a fresh process", async () => {
+      const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
+        cwd: ROOT,
+      })
+
+      expect(out.code).toBe(0)
+    }, 20000)
   })
 
   describe("cleanup", () => {
     const DAY_MS = 24 * 60 * 60 * 1000
-    const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
+    const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
 
     it.effect("deletes files older than 7 days and preserves recent files", () =>
       Effect.gen(function* () {
@@ -142,7 +152,7 @@ describe("Truncate", () => {
 
         yield* writeFileStringScoped(old, "old content")
         yield* writeFileStringScoped(recent, "recent content")
-        yield* TruncateEffect.Service.use((s) => s.cleanup())
+        yield* TruncateSvc.Service.use((s) => s.cleanup())
 
         expect(yield* fs.exists(old)).toBe(false)
         expect(yield* fs.exists(recent)).toBe(true)

+ 4 - 8
packages/web/src/content/docs/ar/providers.mdx

@@ -752,7 +752,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 يجب على مدير GitLab لديك تفعيل ما يلي:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) للمستخدم أو المجموعة أو المثيل
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) للمستخدم أو المجموعة أو المثيل
 2. Feature flags (عبر Rails console):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -774,7 +774,7 @@ callback URL ‏`http://127.0.0.1:8080/callback` ونطاقات الصلاحيا
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+مزيد من التوثيق على صفحة [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### التهيئة
 
@@ -786,11 +786,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -804,7 +800,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/bs/providers.mdx

@@ -760,7 +760,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Vaš GitLab administrator mora omogućiti sljedeće:
 
-1. [Duo Agent Platforma](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) za korisnika, grupu ili instancu
+1. [Duo Agent Platforma](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) za korisnika, grupu ili instancu
 2. Zastavice funkcija (preko Rails konzole):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -782,7 +782,7 @@ Zatim izložite ID aplikacije kao varijablu okruženja:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Više dokumentacije na početnoj stranici [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Konfiguracija
 
@@ -794,11 +794,7 @@ Prilagodite putem `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -812,7 +808,7 @@ Za pristup GitLab alatima (zahtjevi za spajanje, problemi, cjevovodi, CI/CD, itd
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/da/providers.mdx

@@ -749,7 +749,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Din GitLab-administrator skal aktivere følgende:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brugeren, gruppen eller instansen
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brugeren, gruppen eller instansen
 2. Funktionsflag (via Rails-konsollen):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -771,7 +771,7 @@ Udsæt derefter applikations-ID som miljøvariabel:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmesiden.
+Mere dokumentation på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmesiden.
 
 ##### Konfiguration
 
@@ -783,11 +783,7 @@ Tilpas gennem `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -801,7 +797,7 @@ For at få adgang til GitLab-værktøjer (merge requests, problemer, pipelines,
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/de/providers.mdx

@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Ihr GitLab-Administrator muss Folgendes aktivieren:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) für den Benutzer, die Gruppe oder die Instanz
 2. Feature-Flags (über die Rails-Konsole):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -777,7 +777,7 @@ Stellen Sie dann die Anwendung ID als Umgebungsvariable bereit:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth)-Homepage.
+Weitere Dokumentation auf der [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth)-Homepage.
 
 ##### Konfiguration
 
@@ -789,11 +789,7 @@ Anpassen über `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -807,7 +803,7 @@ So greifen Sie auf GitLab-Tools zu (Zusammenführungsanfragen, Probleme, Pipelin
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/es/providers.mdx

@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Su administrador GitLab debe habilitar lo siguiente:
 
-1. [Plataforma de agente Duo](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para el usuario, grupo o instancia
+1. [Plataforma de agente Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para el usuario, grupo o instancia
 2. Indicadores de funciones (a través de la consola Rails):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -779,7 +779,7 @@ Luego exponga el ID de la aplicación como variable de entorno:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Más documentación en la página de inicio de [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Configuración
 
@@ -791,11 +791,7 @@ Personalizar a través de `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -809,7 +805,7 @@ Para acceder a herramientas GitLab (solicitudes de fusión, problemas, canalizac
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/fr/providers.mdx

@@ -763,7 +763,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Votre administrateur GitLab doit activer les éléments suivants :
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) pour l'utilisateur, le groupe ou l'instance
 2. Indicateurs de fonctionnalités (via la console Rails) :
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -785,7 +785,7 @@ Exposez ensuite l'ID de l'application en tant que variable d'environnement :
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Plus de documentation sur la page d'accueil [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Configuration
 
@@ -797,11 +797,7 @@ Personnalisez via `opencode.json` :
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -815,7 +811,7 @@ Pour accéder aux outils GitLab (demandes de fusion, tickets, pipelines, CI/CD,
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/it/providers.mdx

@@ -733,7 +733,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Il tuo amministratore GitLab deve abilitare quanto segue:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) per l'utente, gruppo o istanza
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) per l'utente, gruppo o istanza
 2. Feature flags (via Rails console):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -755,7 +755,7 @@ Poi esponi l'ID applicazione come variabile d'ambiente:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Maggior documentazione sulla homepage di [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Configurazione
 
@@ -767,11 +767,7 @@ Personalizza tramite `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -785,7 +781,7 @@ Per accedere agli strumenti GitLab (merge requests, issues, pipelines, CI/CD, ec
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/ja/providers.mdx

@@ -797,7 +797,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 GitLab 管理者は以下を有効にする必要があります。
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (ユーザー、グループ、またはインスタンス用)
 2. 機能フラグ (Rails コンソール経由):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -819,7 +819,7 @@ GitLab 管理者は以下を有効にする必要があります。
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) ホームページ。
+詳細については、[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) ホームページ。
 
 ##### 設定
 
@@ -831,11 +831,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -849,7 +845,7 @@ GitLab ツール (マージリクエスト、問題、パイプライン、CI/CD
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/ko/providers.mdx

@@ -758,7 +758,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 GitLab 관리자는 다음을 활성화해야 합니다:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) (사용자, 그룹 또는 인스턴스)
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) (사용자, 그룹 또는 인스턴스)
 2. 기능 플래그 (Rails 콘솔을 통해):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -779,7 +779,7 @@ GitLab 관리자는 다음을 활성화해야 합니다:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-[opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
+[opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) 홈페이지에 추가 문서가 있습니다.
 
 #### 구성
 
@@ -791,11 +791,7 @@ export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -809,7 +805,7 @@ GitLab 도구(병합 요청, 이슈, 파이프라인, CI/CD 등)에 액세스하
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/nb/providers.mdx

@@ -757,7 +757,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 GitLab-administratoren din må aktivere følgende:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for brukeren, gruppen eller forekomsten
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) for brukeren, gruppen eller forekomsten
 2. Funksjonsflagg (via Rails-konsollen):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -779,7 +779,7 @@ Utsett deretter applikasjonen ID som miljøvariabel:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) hjemmeside.
+Mer dokumentasjon på [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) hjemmeside.
 
 ##### Konfigurasjon
 
@@ -791,11 +791,7 @@ Tilpass gjennom `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -809,7 +805,7 @@ For å få tilgang til GitLab-verktøy (sammenslåingsforespørsler, problemer,
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/pl/providers.mdx

@@ -755,7 +755,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Twój administrator GitLab musi włączyć następujące opcje:
 
-1. [Platforma Duo Agent](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) dla użytkownika, grupy lub instancji
+1. [Platforma Duo Agent](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) dla użytkownika, grupy lub instancji
 2. Feature flags (via Rails console):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -777,7 +777,7 @@ Następnie ustaw ID aplikacji jako zmienną środowiskową:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Więcej informacji znajdziesz na stronie [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Konfiguracja
 
@@ -789,11 +789,7 @@ Customize through `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -807,7 +803,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 81 - 15
packages/web/src/content/docs/providers.mdx

@@ -544,6 +544,47 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
 
 ---
 
+### Cloudflare Workers AI
+
+Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
+
+1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
+
+2. Set your Account ID as an environment variable.
+
+   ```bash title="~/.bash_profile"
+   export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
+   ```
+
+3. Run the `/connect` command and search for **Cloudflare Workers AI**.
+
+   ```txt
+   /connect
+   ```
+
+4. Enter your Cloudflare API token.
+
+   ```txt
+   ┌ API key
+   │
+   │
+   └ enter
+   ```
+
+   Or set it as an environment variable.
+
+   ```bash title="~/.bash_profile"
+   export CLOUDFLARE_API_KEY=your-api-token
+   ```
+
+5. Run the `/models` command to select a model.
+
+   ```txt
+   /models
+   ```
+
+---
+
 ### Cortecs
 
 1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.
@@ -681,7 +722,20 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
 
 ### GitLab Duo
 
-GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy.
+:::caution[Experimental]
+GitLab Duo support in OpenCode is experimental. Features, configuration, and
+behavior may change in future releases.
+:::
+
+OpenCode integrates with the [GitLab Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/),
+providing AI-powered agentic chat with native tool calling capabilities.
+
+:::note[License requirements]
+GitLab Duo Agent Platform requires a **Premium** or **Ultimate** GitLab
+subscription. It is available on GitLab.com and GitLab Self-Managed.
+See [GitLab Duo Agent Platform prerequisites](https://docs.gitlab.com/user/duo_agent_platform/#prerequisites)
+for full requirements.
+:::
 
 1. Run the `/connect` command and select GitLab.
 
@@ -766,13 +820,15 @@ export GITLAB_TOKEN=glpat-...
 ```
 
 :::note
-Your GitLab administrator must enable the following:
-
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance
-2. Feature flags (via Rails console):
-   - `agent_platform_claude_code`
-   - `third_party_agents_enabled`
-     :::
+Your GitLab administrator must:
+
+1. [Turn on GitLab Duo](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-on-or-off)
+   for the user, group, or instance
+2. [Turn on the Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-gitlab-duo-agent-platform-on-or-off)
+   (GitLab 18.8+) or [enable beta and experimental features](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/#turn-on-beta-and-experimental-features)
+   (GitLab 18.7 and earlier)
+3. For Self-Managed, [configure your instance](https://docs.gitlab.com/administration/gitlab_duo/configure/gitlab_self_managed/)
+   :::
 
 ##### OAuth for Self-Hosted instances
 
@@ -790,7 +846,7 @@ Then expose application ID as environment variable:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage.
+More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth) homepage.
 
 ##### Configuration
 
@@ -802,17 +858,27 @@ Customize through `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
 }
 ```
 
+##### GitLab Duo Agent Platform (DAP) Workflow Models
+
+DAP workflow models provide an alternative execution path that routes tool calls
+through GitLab's Duo Workflow Service (DWS) instead of the standard agentic chat.
+When a `duo-workflow-*` model is selected, OpenCode will:
+
+1. Discover available models from your GitLab namespace
+2. Present a selection picker if multiple models are available
+3. Cache the selected model to disk for fast subsequent startups
+4. Route tool execution requests through OpenCode's permission-gated tool system
+
+Available DAP workflow models follow the `duo-workflow-*` naming convention and
+are dynamically discovered from your GitLab instance.
+
 ##### GitLab API Tools (Optional, but highly recommended)
 
 To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
@@ -820,7 +886,7 @@ To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

+ 4 - 8
packages/web/src/content/docs/pt-br/providers.mdx

@@ -759,7 +759,7 @@ export GITLAB_TOKEN=glpat-...
 :::note
 Seu administrador do GitLab deve habilitar o seguinte:
 
-1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) para o usuário, grupo ou instância
+1. [Duo Agent Platform](https://docs.gitlab.com/user/duo_agent_platform/turn_on_off/) para o usuário, grupo ou instância
 2. Flags de recurso (via console Rails):
    - `agent_platform_claude_code`
    - `third_party_agents_enabled`
@@ -781,7 +781,7 @@ Em seguida, exponha o ID do aplicativo como variável de ambiente:
 export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
 ```
 
-Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth).
+Mais documentação na página [opencode-gitlab-auth](https://www.npmjs.com/package/opencode-gitlab-auth).
 
 ##### Configuração
 
@@ -793,11 +793,7 @@ Personalize através do `opencode.json`:
   "provider": {
     "gitlab": {
       "options": {
-        "instanceUrl": "https://gitlab.com",
-        "featureFlags": {
-          "duo_agent_platform_agentic_chat": true,
-          "duo_agent_platform": true
-        }
+        "instanceUrl": "https://gitlab.com"
       }
     }
   }
@@ -811,7 +807,7 @@ Para acessar ferramentas do GitLab (merge requests, issues, pipelines, CI/CD, et
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["@gitlab/opencode-gitlab-plugin"]
+  "plugin": ["opencode-gitlab-plugin"]
 }
 ```
 

Some files were not shown because too many files changed in this diff