Compare commits

..

21 Commits

Author SHA1 Message Date
Aiden Cline fcb4534a82 fix(core): await initial Copilot model sync 2026-07-22 00:47:24 +00:00
Aiden Cline a0a6963beb fix(core): discover Copilot API endpoint (#38184) 2026-07-21 19:28:31 -05:00
Dax Raad da35eac93f fix(sdk): build without legacy sources 2026-07-21 20:13:27 -04:00
Dax Raad c944048bcf feat(updates): add release control service 2026-07-21 19:58:56 -04:00
Dax Raad c2351e308f fix(ci): skip legacy artifacts on v2 2026-07-21 19:11:52 -04:00
Dax 8de40be6ea feat(plugin): expose app metadata (#38179) 2026-07-21 22:16:25 +00:00
Simon Klee 2ed8fe5960 mini: add hideable footer details (#38192) 2026-07-21 23:28:40 +02:00
Dax 1ea9137c01 fix(core): identify spawned subagents (#38189) 2026-07-21 16:54:01 -04:00
opencode-agent[bot] 77d3289d27 chore: merge dev into v2 (#38182)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
2026-07-21 15:24:47 -05:00
opencode-agent[bot] e6607fb58d fix(core): authorize symlinks by lexical path (#38180)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-21 15:13:18 -05:00
opencode-agent[bot] 128e2550ef fix(core): use shared app node import (#38181)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-07-21 15:08:53 -05:00
Simon Klee e8d273ae5b mini: add monochrome ASCII mode (#38173) 2026-07-21 22:01:09 +02:00
Aiden Cline 7ec1b6e580 feat(core): deliver CodeMode catalog through instructions (#38003) 2026-07-21 14:46:18 -05:00
Simon Klee c4fa5e6619 mini: add turn summary visibility setting (#38153) 2026-07-21 21:01:47 +02:00
Aiden Cline 6ec17e5d55 fix(core): align patch Unicode matching (#38160) 2026-07-21 13:47:52 -05:00
Dax 0405670cab refactor(core): move database path policy (#38159) 2026-07-21 18:44:37 +00:00
opencode-agent[bot] caf727ecb7 fix(core): filter unsupported media inputs (#38145)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-21 13:44:10 -05:00
Dax 9c38358197 refactor: centralize client identity (#38148) 2026-07-21 14:01:04 -04:00
Simon Klee dd6c95fdc7 mini: add quiet transcript settings (#38152) 2026-07-21 19:36:34 +02:00
Aiden Cline 6f4b9504e5 feat(codemode): support custom async iterators (#38141) 2026-07-21 12:18:46 -05:00
Aiden Cline dbfbb13ccc fix(core): authorize patch move destinations (#38133) 2026-07-21 12:18:23 -05:00
191 changed files with 8148 additions and 1793 deletions
+15 -4
View File
@@ -90,11 +90,18 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }} opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build - name: Build legacy CLI
if: github.ref_name != 'v2'
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ steps.committer.outputs.token }}
- name: Build preview CLI
id: build id: build
run: | run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -102,6 +109,7 @@ jobs:
GH_TOKEN: ${{ steps.committer.outputs.token }} GH_TOKEN: ${{ steps.committer.outputs.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with: with:
name: opencode-cli name: opencode-cli
path: | path: |
@@ -109,6 +117,7 @@ jobs:
packages/opencode/dist/opencode-linux* packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'v2'
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows* path: packages/opencode/dist/opencode-windows*
@@ -491,11 +500,13 @@ jobs:
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with: with:
name: opencode-cli name: opencode-cli
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with: with:
name: opencode-cli-windows name: opencode-cli-windows
path: packages/opencode/dist path: packages/opencode/dist
+134
View File
@@ -960,6 +960,20 @@
"solid-js": "^1.9.0", "solid-js": "^1.9.0",
}, },
}, },
"packages/updates": {
"name": "@opencode-ai/updates",
"version": "1.18.4",
"dependencies": {
"jose": "6.0.11",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0",
},
},
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.18.3", "version": "1.18.3",
@@ -2119,6 +2133,8 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@opencode-ai/updates": ["@opencode-ai/updates@workspace:packages/updates"],
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
@@ -6587,6 +6603,8 @@
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/updates/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="], "@opencode-ai/www/@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.44.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260708.1", "unenv": "2.0.0-rc.24", "wrangler": "4.110.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-8wGGunqRcs34o4GRq0Rurp7GZg30xtLJeRGUU81a49r9zQRjlp3xIlsWr3nFlSCso4eE3cjZfiKC/2y116M4TQ=="],
@@ -7857,6 +7875,16 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/updates/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
"@opencode-ai/updates/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
"@opencode-ai/updates/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@opencode-ai/updates/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="],
"@opencode-ai/updates/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -8705,6 +8733,72 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@opencode-ai/updates/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@opencode-ai/updates/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="],
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
@@ -8989,6 +9083,44 @@
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], "@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="],
"@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="], "@opencode-ai/www/@cloudflare/vite-plugin/@cloudflare/unenv-preset/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="],
@@ -9139,6 +9271,8 @@
"@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@mintlify/common/sucrase/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@opencode-ai/updates/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@opencode-ai/www/@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], "@opencode-ai/www/wrangler/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
+128 -25
View File
@@ -17,6 +17,11 @@ import {
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
@@ -1002,7 +1007,7 @@ function HomeServerRow(props: {
) )
} }
function HomeProjectList(props: { type HomeProjectListProps = {
server: ServerConnection.Any server: ServerConnection.Any
projects: LocalProject[] projects: LocalProject[]
selected: HomeProjectSelection selected: HomeProjectSelection
@@ -1013,29 +1018,82 @@ function HomeProjectList(props: {
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }
function HomeProjectList(props: HomeProjectListProps) {
const global = useGlobal()
let listRef!: HTMLDivElement
const projects = () => global.ensureServerCtx(props.server).projects
return ( return (
<div class="flex min-w-0 flex-col gap-1"> <DragDropProvider
<For each={props.projects}> sensors={(defaults) => [
{(project) => ( ...defaults.filter((sensor) => sensor !== PointerSensor),
<HomeProjectRow PointerSensor.configure({
project={project} activationConstraints: (event) =>
server={props.server} event.pointerType === "touch"
selected={ ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
props.selected.server === ServerConnection.key(props.server) && : [new PointerActivationConstraints.Distance({ value: 4 })],
props.selected.directory === project.worktree preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
} }),
unseenCount={props.unseenCount(props.server, project)} ]}
selectProject={props.selectProject} modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
openNewSession={props.openNewSession} plugins={(defaults) => [
editProject={props.editProject} ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
closeProject={props.closeProject} AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
clearNotifications={props.clearNotifications} Feedback.configure({ dropAnimation: null }),
language={props.language} ]}
/> onDragEnd={(event) => {
)} const source = event.operation.source
</For> if (event.canceled || !isSortable(source)) return
</div> if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
if (props.selected.server !== ServerConnection.key(props.server))
props.selectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.projects.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selected.server === ServerConnection.key(props.server)}
selected={
props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
}
unseenCount={props.unseenCount(props.server, item())}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</Show>
) )
} }
@@ -1115,6 +1173,8 @@ function HomeRecentlyClosedRow(props: {
function HomeProjectRow(props: { function HomeProjectRow(props: {
project: LocalProject project: LocalProject
server: ServerConnection.Any server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean selected: boolean
unseenCount: number unseenCount: number
selectProject: (server: ServerConnection.Any, directory: string) => void selectProject: (server: ServerConnection.Any, directory: string) => void
@@ -1128,6 +1188,15 @@ function HomeProjectRow(props: {
const platform = usePlatform() const platform = usePlatform()
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const canRevealInFileManager = () => const canRevealInFileManager = () =>
platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server) platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
const fileManagerActionLabel = () => const fileManagerActionLabel = () =>
@@ -1144,15 +1213,49 @@ function HomeProjectRow(props: {
) )
} }
return ( return (
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"> <div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button <button
type="button" type="button"
data-component="home-project-row" data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined} aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()} disabled={serverUnreachable()}
onClick={() => props.selectProject(props.server, props.project.worktree)} onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.selectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.selectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
> >
<HomeProjectAvatar project={props.project} /> <HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span> <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
+8
View File
@@ -3,6 +3,7 @@ import { $ } from "bun"
import pkg from "../package.json" import pkg from "../package.json"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { UpdateArtifact } from "../../../script/update-artifact"
const dir = fileURLToPath(new URL("..", import.meta.url)) const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir) process.chdir(dir)
@@ -81,3 +82,10 @@ await publishDistribution({
binary: "opencode2-node", binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-", packagePrefix: "@opencode-ai/cli-node-",
}) })
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+2 -2
View File
@@ -38,7 +38,7 @@ import type {
SetSessionModeRequest, SetSessionModeRequest,
SetSessionModeResponse, SetSessionModeResponse,
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content" import { promptContentToParts } from "./content"
@@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
}, },
authMethods: [authMethod], authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: InstallationVersion }, agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
} }
}, },
authenticate: async (params) => { authenticate: async (params) => {
@@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater" import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight" import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
export default Runtime.handler(Commands, (input) => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
const runPromise = Effect.runPromiseWith(context) const runPromise = Effect.runPromiseWith(context)
const service = server.service const service = server.service
yield* run({ yield* run({
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
server: { server: {
endpoint: server.endpoint, endpoint: server.endpoint,
service: service service: service
@@ -32,6 +32,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replayLimit: Option.getOrUndefined(input.replayLimit), replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo, demo: input.demo,
tuiConfig: resolved, tuiConfig: resolved,
config: {
update: (update) => runServicePromise(config.update(update)),
},
}), }),
) )
}), }),
+10 -8
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node" import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect, Layer } from "effect" import { Effect } from "effect"
import { Commands } from "./commands/commands" import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime" import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode-ai/util/observability" import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Updater } from "./services/updater" import { Updater } from "./services/updater"
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version" import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
@@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
}) })
Effect.logInfo("cli starting", { Effect.logInfo("cli starting", {
version: InstallationVersion, version: OPENCODE_VERSION,
channel: InstallationChannel, channel: OPENCODE_CHANNEL,
local: InstallationLocal, local: OPENCODE_LOCAL,
args: process.argv.slice(2), args: process.argv.slice(2),
}).pipe( }).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
Effect.annotateLogs({ role: "cli" }), Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer), Effect.provide(Config.layer),
Effect.provide(Updater.layer), Effect.provide(Updater.layer),
@@ -74,7 +73,10 @@ Effect.logInfo("cli starting", {
Observability.layer({ Observability.layer({
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
}).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))), client: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
}),
), ),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
Effect.scoped, Effect.scoped,
+2
View File
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
replayLimit?: number replayLimit?: number
demo?: boolean demo?: boolean
tuiConfig?: MiniFrontendInput["tuiConfig"] tuiConfig?: MiniFrontendInput["tuiConfig"]
config?: MiniFrontendInput["config"]
} }
type Model = MiniFrontendInput["model"] type Model = MiniFrontendInput["model"]
@@ -119,6 +120,7 @@ export async function runMini(input: MiniCommandInput) {
replayLimit: input.replayLimit, replayLimit: input.replayLimit,
demo: input.demo, demo: input.demo,
tuiConfig: input.tuiConfig, tuiConfig: input.tuiConfig,
config: input.config,
}) })
}) })
if (result.exitCode !== 0) process.exit(result.exitCode) if (result.exitCode !== 0) process.exit(result.exitCode)
+14 -4
View File
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service" import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto" import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path" import path from "node:path"
@@ -69,13 +69,23 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const instanceID = randomUUID() const instanceID = randomUUID()
const server = yield* start( const server = yield* start(
{ {
client: process.env.OPENCODE_CLIENT ?? "cli", app: {
name: process.env.OPENCODE_CLIENT ?? "cli",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
},
hostname, hostname,
port, port,
password, password,
simulation: truthy(process.env.OPENCODE_SIMULATE), simulation: truthy(process.env.OPENCODE_SIMULATE),
database: { database: {
path: process.env.OPENCODE_DB, path:
process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
}, },
models: { models: {
url: process.env.OPENCODE_MODELS_URL, url: process.env.OPENCODE_MODELS_URL,
@@ -167,7 +177,7 @@ const register = Effect.fnUntraced(function* (
yield* fs.makeDirectory(path.dirname(file), { recursive: true }) yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = { const info = {
id, id,
version: InstallationVersion, version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address), url: HttpServer.formatAddress(address),
pid: process.pid, pid: process.pid,
password, password,
@@ -1,6 +1,6 @@
import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service" import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../version"
import { Effect, Redacted } from "effect" import { Effect, Redacted } from "effect"
import { Env } from "../env" import { Env } from "../env"
import { ServiceConfig } from "./service-config" import { ServiceConfig } from "./service-config"
@@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause), catch: (cause) => connectError(endpoint, cause),
}) })
if (health.version !== InstallationVersion) if (health.version !== OPENCODE_VERSION)
process.stderr.write( process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
) )
return { endpoint } satisfies Resolved return { endpoint } satisfies Resolved
} }
+10 -10
View File
@@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
import { Hash } from "@opencode-ai/util/hash" import { Hash } from "@opencode-ai/util/hash"
import { Service } from "@opencode-ai/client/effect/service" import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect" import { Effect, FileSystem, Option, Schema } from "effect"
@@ -24,26 +24,26 @@ type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) { export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json" if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
} }
export function defaultPort(channel = InstallationChannel) { export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000) return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
} }
export function legacyFilename(channel = InstallationChannel) { export function legacyFilename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "local") return if (channel === "latest" || channel === "local") return
return `service-${Hash.fast(channel)}.json` return `service-${Hash.fast(channel)}.json`
} }
export function versionBelongsToChannel( export function versionBelongsToChannel(
version: string | undefined, version: string | undefined,
channel = InstallationChannel, channel = OPENCODE_CHANNEL,
installedVersion = InstallationVersion, installedVersion = OPENCODE_VERSION,
) { ) {
if (version === undefined) return false if (version === undefined) return false
if (version === installedVersion) return true if (version === installedVersion) return true
@@ -55,8 +55,8 @@ export function versionBelongsToChannel(
export const migrateRegistration = Effect.fnUntraced(function* ( export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string, legacy: string,
file: string, file: string,
channel = InstallationChannel, channel = OPENCODE_CHANNEL,
installedVersion = InstallationVersion, installedVersion = OPENCODE_VERSION,
) { ) {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option) const text = yield* fs.readFileString(legacy).pipe(Effect.option)
@@ -92,7 +92,7 @@ const paths = Effect.gen(function* () {
legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined, legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
legacyRegistrationFiles: [ legacyRegistrationFiles: [
...(legacy ? [path.join(global.state, legacy)] : []), ...(legacy ? [path.join(global.state, legacy)] : []),
...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []), ...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
], ],
configFile: path.join(global.config, name), configFile: path.join(global.config, name),
} }
@@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () {
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file)) yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
return { return {
file, file,
version: InstallationVersion, version: OPENCODE_VERSION,
command: [...selfCommand(), "serve", "--service"], command: [...selfCommand(), "serve", "--service"],
} }
}) })
@@ -3,7 +3,7 @@
// version-mismatched background service before the TUI attaches. // version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core" import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid" import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo" import { go } from "@opencode-ai/tui/logo"
@@ -356,12 +356,12 @@ function UpdateFooter(props: {
] as const) ] as const)
: []), : []),
["to", colors.muted], ["to", colors.muted],
[InstallationVersion, colors.accent], [OPENCODE_VERSION, colors.accent],
) )
const completedHeader = phrase( const completedHeader = phrase(
["OpenCode", colors.muted, true], ["OpenCode", colors.muted, true],
["updated to", colors.muted], ["updated to", colors.muted],
[InstallationVersion, colors.accent], [OPENCODE_VERSION, colors.accent],
) )
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted]) const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
const outcomeStatus = () => const outcomeStatus = () =>
+11 -15
View File
@@ -1,10 +1,6 @@
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/util/installation/version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect" import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser" import { parse, type ParseError } from "jsonc-parser"
@@ -52,7 +48,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service const global = yield* Global.Service
const appProcess = yield* AppProcess.Service const appProcess = yield* AppProcess.Service
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () { const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) => const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -99,8 +95,8 @@ export const layer = Layer.effect(
const response = yield* Effect.tryPromise({ const response = yield* Effect.tryPromise({
try: () => try: () =>
fetch( fetch(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`, `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(OPENCODE_CHANNEL)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
), ),
catch: (cause) => new Error("Failed to check for updates", { cause }), catch: (cause) => new Error("Failed to check for updates", { cause }),
}) })
@@ -138,13 +134,13 @@ export const layer = Layer.effect(
const check = Effect.fn("cli.updater.check")(function* () { const check = Effect.fn("cli.updater.check")(function* () {
if ( if (
InstallationLocal || OPENCODE_LOCAL ||
["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "") ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
) )
return yield* Effect.logInfo("update check skipped", { return yield* Effect.logInfo("update check skipped", {
reason: InstallationLocal ? "local-install" : "disabled", reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: InstallationVersion, version: OPENCODE_VERSION,
channel: InstallationChannel, channel: OPENCODE_CHANNEL,
}) })
const policy = yield* readPolicy() const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
@@ -152,15 +148,15 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const version = yield* latest() const version = yield* latest()
yield* Effect.logInfo("update check", { yield* Effect.logInfo("update check", {
current: InstallationVersion, current: OPENCODE_VERSION,
latest: version, latest: version,
}) })
const next = action(InstallationVersion, version, policy) const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" }) if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
const detected = yield* method() const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found") if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version) yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected }) yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
}) })
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause }))) }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
+8
View File
@@ -0,0 +1,8 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export const OPENCODE_LOCAL = channel === "local"
+6 -1
View File
@@ -131,11 +131,16 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service const service = yield* Config.Service
return yield* service.update((draft) => { return yield* service.update((draft) => {
draft.prompt = { paste: "compact" } draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
}) })
}), }),
) )
expect(config).toEqual({ animations: true, prompt: { paste: "compact" } }) expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment") expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally { } finally {
await Bun.$`rm -rf ${directory}` await Bun.$`rm -rf ${directory}`
+4 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise" import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../src/version"
import path from "node:path" import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini" import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run" import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
@@ -31,14 +31,14 @@ describe("mini command", () => {
const initial = Bun.serve({ const initial = Bun.serve({
port: 0, port: 0,
fetch() { fetch() {
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}, },
}) })
const replacement = Bun.serve({ const replacement = Bun.serve({
port: 0, port: 0,
fetch(request) { fetch(request) {
authorization.push(request.headers.get("authorization")) authorization.push(request.headers.get("authorization"))
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}, },
}) })
const controller = new AbortController() const controller = new AbortController()
@@ -145,7 +145,7 @@ describe("mini command", () => {
fetch(request) { fetch(request) {
const url = new URL(request.url) const url = new URL(request.url)
if (url.pathname === "/api/health") if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
if (url.pathname === "/api/location") if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/model") { if (url.pathname === "/api/model") {
+3 -3
View File
@@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Effect, FileSystem, Scope } from "effect" import { Effect, FileSystem, Scope } from "effect"
import fs from "node:fs/promises" import fs from "node:fs/promises"
@@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
fetch() { fetch() {
return Response.json({ return Response.json({
healthy: true, healthy: true,
version: InstallationVersion, version: OPENCODE_VERSION,
pid: process.pid, pid: process.pid,
}) })
}, },
@@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
registration, registration,
JSON.stringify({ JSON.stringify({
id, id,
version: InstallationVersion, version: OPENCODE_VERSION,
url: server.url.toString(), url: server.url.toString(),
pid: process.pid, pid: process.pid,
}), }),
+5 -5
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Service, type Info } from "@opencode-ai/client/effect/service" import { Service, type Info } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import fs from "node:fs/promises" import fs from "node:fs/promises"
@@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
) )
const stale = { const stale = {
id: "stale", id: "stale",
version: InstallationVersion, version: OPENCODE_VERSION,
url: "http://127.0.0.1:1", url: "http://127.0.0.1:1",
pid: process.pid, pid: process.pid,
password: "stale", password: "stale",
@@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() { fetch() {
requests.count += 1 requests.count += 1
if (requests.count === 2) recognizing.resolve() if (requests.count === 2) recognizing.resolve()
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 }) return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
}, },
}) })
const registration = path.join(root, "state", "opencode", "service-local.json") const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
registration, registration,
JSON.stringify({ JSON.stringify({
id: "stale", id: "stale",
version: InstallationVersion, version: OPENCODE_VERSION,
url: "http://127.0.0.1:1", url: "http://127.0.0.1:1",
pid: 2_147_483_647, pid: 2_147_483_647,
password: "stale", password: "stale",
@@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
await Bun.sleep(8_000) await Bun.sleep(8_000)
const info = { const info = {
id: "incumbent", id: "incumbent",
version: InstallationVersion, version: OPENCODE_VERSION,
url: `http://127.0.0.1:${listener.port}`, url: `http://127.0.0.1:${listener.port}`,
pid: process.pid, pid: process.pid,
password: "incumbent", password: "incumbent",
+7 -4
View File
@@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Regular-expression literals. - [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals. - [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries. - [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [ ] Symbol primitive values and symbol-keyed properties. - [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls. - [ ] Tagged-template calls.
- [ ] Getter and setter definitions in object literals. - [ ] Getter and setter definitions in object literals.
@@ -70,9 +71,11 @@ ultimate source of truth.
- [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values. - [x] `throw` with arbitrary values.
- [x] Labeled statements, labeled `break`, and labeled `continue`. - [x] Labeled statements, labeled `break`, and labeled `continue`.
- [x] `for await...of` over the supported synchronous collections, awaiting each yielded CodeMode promise or plain - [x] `for await...of` over the supported synchronous collections and custom iterator objects using
value before binding it. Custom sync/async iterator objects, `Symbol.asyncIterator`, and async generators remain `Symbol.asyncIterator` or the `Symbol.iterator` fallback. Each iterator step is sequential, yielded promises and
outside the supported subset. plain values from synchronous collections and sync iterators are awaited before binding, and abrupt loop
completion invokes the iterator's optional `return()`. Custom async iterators control their yielded values, as in
JavaScript; only their `next()` results are awaited. Async generators remain outside the supported subset.
## Functions and callbacks ## Functions and callbacks
+7 -1
View File
@@ -36,7 +36,7 @@ export type StatementResult =
export type MemberReference = { export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
key: string | number key: PropertyKey
} }
export class CodeModeFunction { export class CodeModeFunction {
@@ -61,6 +61,12 @@ export class ComputedValue {
export class PromiseNamespace {} export class PromiseNamespace {}
export class SymbolNamespace {}
export const AsyncIteratorSymbol: unique symbol = Symbol("codemode.async-iterator")
export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject" export type PromiseMethodName = "all" | "allSettled" | "race" | "any" | "resolve" | "reject"
export class PromiseMethodReference { export class PromiseMethodReference {
@@ -13,6 +13,7 @@ import {
PromiseMethodReference, PromiseMethodReference,
PromiseNamespace, PromiseNamespace,
SearchFunction, SearchFunction,
SymbolNamespace,
UriFunction, UriFunction,
} from "./model.js" } from "./model.js"
import { ToolReference } from "../tool-runtime.js" import { ToolReference } from "../tool-runtime.js"
@@ -34,6 +35,7 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SearchFunction || value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction || value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference || value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value) isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> { function* childValues(value: object): Generator<unknown> {
@@ -113,7 +115,8 @@ export const typeofValue = (value: unknown): string => {
value instanceof PromiseInstanceMethodReference || value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace || value instanceof PromiseNamespace ||
value instanceof PromiseCapabilityFunction || value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace
) )
return "function" return "function"
if (value instanceof UriFunction || value instanceof SearchFunction) return "function" if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
+195 -41
View File
@@ -1,7 +1,8 @@
import { Cause, Effect } from "effect" import { Cause, Effect, Exit } from "effect"
import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js" import { isBlockedMember, ToolReference, ToolRuntimeError, type SafeObject } from "../tool-runtime.js"
import { import {
type AstNode, type AstNode,
AsyncIteratorSymbol,
asNode, asNode,
type Binding, type Binding,
CodeModeFunction, CodeModeFunction,
@@ -19,6 +20,8 @@ import {
IntrinsicReference, IntrinsicReference,
InterpreterRuntimeError, InterpreterRuntimeError,
isRecord, isRecord,
IteratorSymbol,
IteratorSymbols,
JsonMethodReference, JsonMethodReference,
type MemberReference, type MemberReference,
OptionalShortCircuit, OptionalShortCircuit,
@@ -30,6 +33,7 @@ import {
ProgramThrow, ProgramThrow,
type ProgramNode, type ProgramNode,
SearchFunction, SearchFunction,
SymbolNamespace,
type StatementResult, type StatementResult,
supportedSyntaxMessage, supportedSyntaxMessage,
unsupportedSyntax, unsupportedSyntax,
@@ -211,6 +215,12 @@ const loopDeclaration = (left: AstNode, statement: "for...of" | "for...in") => {
} }
} }
type CustomIterator = {
iterator: SafeObject
next: unknown
asynchronous: boolean
}
export class Interpreter<R> { export class Interpreter<R> {
private scopes: ScopeStack private scopes: ScopeStack
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R> private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
@@ -241,6 +251,7 @@ export class Interpreter<R> {
globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("search", { mutable: false, value: new SearchFunction() }) globalScope.set("search", { mutable: false, value: new SearchFunction() })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("Symbol", { mutable: false, value: new SymbolNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined }) globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
@@ -636,9 +647,10 @@ export class Interpreter<R> {
const body = getNode(node, "body") const body = getNode(node, "body")
const iterable = spreadItems(right) const iterable = spreadItems(right)
if (iterable === undefined) { const iterator = iterable === undefined && awaiting ? yield* self.customIterator(right, node) : undefined
if (iterable === undefined && iterator === undefined) {
throw new InterpreterRuntimeError( throw new InterpreterRuntimeError(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams value.`, `${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams${awaiting ? ", or custom iterator" : ""} value.`,
node, node,
) )
} }
@@ -657,19 +669,14 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left) throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
} }
for (const value of iterable) { const evaluateBody = (value: unknown) =>
const resolved = awaiting Effect.gen(function* () {
? value instanceof CodeModePromise
? yield* self.settlePromise(value)
: yield* Effect.as(Effect.yieldNow, value)
: value
const result = yield* Effect.gen(function* () {
if (declared) { if (declared) {
self.scopes.push() self.scopes.push()
if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left) if (declared.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
yield* self.declarePattern(declared.pattern, resolved, declared.mutable, left, declared.lexical) yield* self.declarePattern(declared.pattern, value, declared.mutable, left, declared.lexical)
} else if (assignment) { } else if (assignment) {
yield* self.assignPattern(assignment, resolved, left) yield* self.assignPattern(assignment, value, left)
} }
return yield* self.evaluateStatement(body) return yield* self.evaluateStatement(body)
}).pipe( }).pipe(
@@ -680,22 +687,48 @@ export class Interpreter<R> {
), ),
) )
if (iterable !== undefined) {
for (const value of iterable) {
const result = yield* evaluateBody(awaiting ? yield* self.awaitValue(value) : value)
if (result.kind === "return") return result
if (result.kind === "break") {
if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult
}
if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result
}
return { kind: "none" } satisfies StatementResult
}
if (iterator === undefined) throw new InterpreterRuntimeError("Custom iterator is unavailable.", node)
while (true) {
const step = yield* self.nextIteratorResult(iterator, node)
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
if (!Exit.isSuccess(bodyExit)) {
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
if (!Cause.hasInterruptsOnly(bodyExit.cause)) yield* Effect.exit(self.closeIterator(iterator, node))
return yield* Effect.failCause(bodyExit.cause)
}
const result = bodyExit.value
if (result.kind === "return") { if (result.kind === "return") {
yield* self.closeIterator(iterator, node)
return result return result
} }
if (result.kind === "break") { if (result.kind === "break") {
yield* self.closeIterator(iterator, node)
if (result.label !== undefined && !labels?.has(result.label)) return result if (result.label !== undefined && !labels?.has(result.label)) return result
return { kind: "none" } satisfies StatementResult return { kind: "none" } satisfies StatementResult
} }
if (result.kind === "continue") { if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) {
if (result.label !== undefined && !labels?.has(result.label)) return result yield* self.closeIterator(iterator, node)
continue return result
} }
} }
return { kind: "none" } satisfies StatementResult
}).pipe( }).pipe(
Effect.ensuring( Effect.ensuring(
Effect.sync(() => { Effect.sync(() => {
@@ -705,6 +738,101 @@ export class Interpreter<R> {
) )
} }
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return value instanceof CodeModePromise ? this.settlePromise(value) : Effect.as(Effect.yieldNow, value)
}
private customIterator(value: unknown, node: AstNode) {
if (!isRecord(value) || isRuntimeReference(value)) return Effect.succeed(undefined)
const asyncMethod = Reflect.get(value, AsyncIteratorSymbol)
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.succeed(undefined)
const self = this
return Effect.map(
this.invokeCallable(this.requireIteratorMethod(method, "Iterator method", node), [], node),
(iterator) => {
const object = self.requireIteratorObject(iterator, "Iterator method result", node)
return {
iterator: object,
next: self.requireIteratorMethod(object.next, "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
const object = self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(iterator.next, [], node)),
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
}),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
return { done: captured.value.done, value: yield* self.awaitValue(captured.value.value) }
})
}
private closeIterator(iterator: CustomIterator, node: AstNode): Effect.Effect<void, unknown, R> {
const close = iterator.iterator.return
if (close === undefined || close === null) return iterator.asynchronous ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
const method = self.requireIteratorMethod(close, "Iterator return", node)
if (iterator.asynchronous) {
self.requireIteratorObject(
yield* self.awaitValue(yield* self.invokeCallable(method, [], node)),
"Iterator return() result",
node,
)
return
}
const called = yield* Effect.exit(self.invokeCallable(method, [], node))
if (!Exit.isSuccess(called)) {
yield* Effect.yieldNow
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
)
if (!Exit.isSuccess(captured)) {
yield* Effect.yieldNow
return yield* Effect.failCause(captured.cause)
}
yield* self.awaitValue(captured.value)
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node).as("TypeError")
}
private enumerableKeys(value: unknown): Array<string> | undefined { private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) { if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)] return [...this.toolKeys(value.path)]
@@ -922,7 +1050,7 @@ export class Interpreter<R> {
) )
} }
const consumed = new Set<string>() const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) { for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties") const property = asNode(propertyValue, "properties")
@@ -931,6 +1059,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(value as SafeObject)) { for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
} }
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(value, symbol))
Reflect.set(rest, symbol, Reflect.get(value, symbol))
}
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize) yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property, initialize)
continue continue
} }
@@ -939,7 +1071,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
} }
consumed.add(String(key)) consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern( yield* self.declarePattern(
getNode(property, "value"), getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key), self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
@@ -1002,7 +1134,7 @@ export class Interpreter<R> {
} }
const source = value as SafeObject | Array<unknown> const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>() const consumed = new Set<PropertyKey>()
for (const propertyValue of getArray(pattern, "properties")) { for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties") const property = asNode(propertyValue, "properties")
if (property.type === "RestElement") { if (property.type === "RestElement") {
@@ -1010,6 +1142,10 @@ export class Interpreter<R> {
for (const [key, item] of Object.entries(source)) { for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
} }
for (const symbol of IteratorSymbols) {
if (!consumed.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(rest, symbol, Reflect.get(source, symbol))
}
yield* self.assignPattern(getNode(property, "argument"), rest, property) yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue continue
} }
@@ -1017,7 +1153,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
} }
consumed.add(String(key)) consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property) yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
} }
return return
@@ -1044,7 +1180,7 @@ export class Interpreter<R> {
}) })
} }
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> { private destructuringPropertyKey(property: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") { if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property) throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
} }
@@ -1055,12 +1191,12 @@ export class Interpreter<R> {
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)) return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
} }
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown { private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return source[String(key)] if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length if (key === "length") return source.length
if (typeof key === "number") return source[key] if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key] if (Object.hasOwn(source, key)) return Reflect.get(source, key)
if (arrayMethods.has(key)) return new IntrinsicReference(source, key) if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined return undefined
} }
@@ -1690,6 +1826,12 @@ export class Interpreter<R> {
if (callable instanceof PromiseNamespace) { if (callable instanceof PromiseNamespace) {
throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError") throw new InterpreterRuntimeError("Constructor Promise requires 'new'.", node).as("TypeError")
} }
if (callable instanceof SymbolNamespace) {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
).as("TypeError")
}
if (callable instanceof PromiseCapabilityFunction) { if (callable instanceof PromiseCapabilityFunction) {
callable.settle(args[0]) callable.settle(args[0])
return undefined return undefined
@@ -1800,6 +1942,9 @@ export class Interpreter<R> {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property) if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value objectValue[key] = value
} }
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(spread, symbol)) Reflect.set(objectValue, symbol, Reflect.get(spread, symbol))
}
continue continue
} }
@@ -1830,7 +1975,7 @@ export class Interpreter<R> {
if (isBlockedMember(String(key))) { if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode) throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
} }
objectValue[String(key)] = yield* self.evaluateExpression(valueNode) Reflect.set(objectValue, key, yield* self.evaluateExpression(valueNode))
} }
return objectValue return objectValue
@@ -1955,6 +2100,12 @@ export class Interpreter<R> {
) )
} }
if (objectValue instanceof SymbolNamespace) {
if (key === "asyncIterator") return new ComputedValue(AsyncIteratorSymbol)
if (key === "iterator") return new ComputedValue(IteratorSymbol)
return new ComputedValue(undefined)
}
if (objectValue instanceof GlobalNamespace) { if (objectValue instanceof GlobalNamespace) {
if (typeof key === "string" && isBlockedMember(key)) { if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode) throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
@@ -1976,7 +2127,7 @@ export class Interpreter<R> {
if (typeof objectValue === "string") { if (typeof objectValue === "string") {
if (key === "length") return new ComputedValue(objectValue.length) if (key === "length") return new ComputedValue(objectValue.length)
const index = parseArrayIndex(key) const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return new ComputedValue(objectValue[index]) if (index !== undefined) return new ComputedValue(objectValue[index])
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined) return new ComputedValue(undefined)
@@ -2072,7 +2223,7 @@ export class Interpreter<R> {
if (Array.isArray(objectValue)) { if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key } if (operation === "delete") return { target: objectValue, key }
const index = parseArrayIndex(key) const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) { if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) { if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key]) return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
@@ -2103,13 +2254,13 @@ export class Interpreter<R> {
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return reference.target[reference.key] return Reflect.get(reference.target, reference.key)
} }
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) { if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)] return Reflect.get(reference.target.url, reference.key)
} }
return reference.target[String(reference.key)] return Reflect.get(reference.target, reference.key)
}) })
} }
@@ -2171,22 +2322,22 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node) throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
} }
} }
const key = Array.isArray(reference.target) ? reference.key : String(reference.key) const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key)) const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node) if (write) self.assignToReference(reference, key, next, node)
return result return result
}) })
} }
private readReferenceValue(reference: MemberReference, key: number | string): unknown { private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) { if (reference.target instanceof CodeModeURL) {
return (reference.target.url as unknown as Record<string, unknown>)[key] return Reflect.get(reference.target.url, key)
} }
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
return (reference.target as Record<PropertyKey, unknown>)[key] return Reflect.get(reference.target, key)
} }
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) { if (Array.isArray(reference.target)) {
const target = reference.target const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) { if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
@@ -2219,16 +2370,19 @@ export class Interpreter<R> {
return return
} }
const target = reference.target as SafeObject const target = reference.target as SafeObject
const objectKey = key as string
rejectCircularInsertion(target, next, "Object assignment result", node) rejectCircularInsertion(target, next, "Object assignment result", node)
target[objectKey] = next Reflect.set(target, key, next)
} }
private toPropertyKey(value: unknown, node: AstNode): string | number { private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
if (typeof value === "string" || typeof value === "number") { if (typeof value === "string" || typeof value === "number") {
return value return value
} }
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError("Property key must be a string or number.", node) throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
} }
} }
+14 -2
View File
@@ -1,4 +1,10 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js" import { containsOpaqueReference } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js" import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js" import { isCodeModeValue, CodeModeMap, CodeModePromise, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
@@ -46,7 +52,10 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
case "entries": case "entries":
return Object.entries(requireObject()).map(([key, item]) => [key, item]) return Object.entries(requireObject()).map(([key, item]) => [key, item])
case "hasOwn": case "hasOwn":
return Object.hasOwn(requireObject(), String(args[1])) return Object.hasOwn(
requireObject(),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
)
case "is": case "is":
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) { if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue") throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
@@ -64,6 +73,9 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects data objects.", node) throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
} }
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item) for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
}
} }
return out return out
} }
@@ -1,10 +1,15 @@
/* /*
* Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
* - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js * - test/language/statements/for-await-of/ticks-with-sync-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/ticks-with-async-iter-resolved-promise-and-constructor-lookup.js
* - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js * - test/language/statements/for-await-of/async-func-dstr-let-ary-ptrn-elem-id-iter-val.js
* - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js * - test/language/statements/for-await-of/async-func-decl-dstr-array-rest-after-element.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-is-null.js
* - test/language/statements/for-await-of/iterator-close-non-throw-get-method-non-callable.js
* - test/language/statements/for-await-of/iterator-close-throw-get-method-non-callable.js
* *
* Copyright (C) 2019 André Bargull. All rights reserved. * Copyright (C) 2019 André Bargull. All rights reserved.
* Copyright (C) 2020 Alexey Shvayka. All rights reserved.
* Test262 portions are governed by the BSD license in LICENSE.test262. * Test262 portions are governed by the BSD license in LICENSE.test262.
*/ */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
@@ -130,10 +135,400 @@ describe("Test262 for-await-of adaptations", () => {
).toEqual([1, 3]) ).toEqual([1, 3])
}) })
test("keeps custom iterator objects outside the supported subset", async () => { test("drives a custom async iterator sequentially", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
async next() {
index += 1
if (index > 3) return { done: true }
return { done: false, value: index }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2, 3])
})
test("leaves async iterator values under the iterator's control", async () => {
expect(
await value(`
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) return [item instanceof Promise, await item]
`),
).toEqual([true, 1])
})
test("awaits synchronous results from an async iterator before the body", async () => {
expect(
await value(`
const events = ["pre"]
const ticks = Promise.resolve()
.then(() => events.push("tick 1"))
.then(() => events.push("tick 2"))
let done = false
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: () => done ? { done: true } : (done = true, { done: false, value: Promise.resolve(1) }),
}
for await (const item of iterator) events.push(item instanceof Promise ? "loop" : "adopted")
events.push("post")
await ticks
return events
`),
).toEqual(["pre", "tick 1", "loop", "tick 2", "post"])
})
test("falls back to a custom synchronous iterator", async () => {
expect(
await value(`
let index = 0
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
index += 1
return index > 2 ? { done: true } : { done: false, value: Promise.resolve(index) }
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("adopts terminal and close values from synchronous iterators", async () => {
expect(
await value(`
const terminal = {
[Symbol.iterator]: () => terminal,
next: () => ({ done: true, value: Promise.reject("terminal") }),
}
let terminalError
try {
for await (const item of terminal) {}
} catch (error) {
terminalError = error
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return: () => ({ done: true, value: Promise.reject("close") }),
}
let closeError
try {
for await (const item of closing) break
} catch (error) {
closeError = error
}
return [terminalError, closeError]
`),
).toEqual(["terminal", "close"])
})
test("captures the next method when acquiring the iterator", async () => {
expect(
await value(`
let count = 0
const next = () => {
count += 1
if (count === 1) iterator.next = () => ({ done: true })
return count > 2 ? { done: true } : { done: false, value: count }
}
const iterator = { [Symbol.iterator]: () => iterator, next }
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1, 2])
})
test("captures synchronous iterator result fields before suspending", async () => {
expect(
await value(`
let count = 0
const result = { done: false, value: 1 }
const iterator = {
[Symbol.iterator]: () => iterator,
next() {
count += 1
if (count > 1) return { done: true }
Promise.resolve().then(() => {
result.done = true
result.value = 2
})
return result
},
}
const values = []
for await (const item of iterator) values.push(item)
return values
`),
).toEqual([1])
})
test("preserves the async close turn for a sync iterator without return", async () => {
expect(
await value(`
const events = []
const iterator = {
[Symbol.iterator]: () => iterator,
next: () => ({ done: false, value: 1 }),
}
for await (const item of iterator) {
Promise.resolve().then(() => events.push("reaction"))
break
}
events.push("after")
return events
`),
).toEqual(["reaction", "after"])
})
test("defers synchronous iterator protocol errors", async () => {
expect(
await value(`
const events = []
const throwing = {
[Symbol.iterator]: () => throwing,
next() {
Promise.resolve().then(() => events.push("next reaction"))
throw "next"
},
}
try {
for await (const item of throwing) {}
} catch (error) {
events.push("next catch")
}
const malformed = {
[Symbol.iterator]: () => malformed,
next() {
Promise.resolve().then(() => events.push("result reaction"))
return 1
},
}
try {
for await (const item of malformed) {}
} catch (error) {
events.push("result catch")
}
const closing = {
[Symbol.iterator]: () => closing,
next: () => ({ done: false, value: 1 }),
return() {
Promise.resolve().then(() => events.push("return reaction"))
throw "return"
},
}
try {
for await (const item of closing) break
} catch (error) {
events.push("return catch")
}
return events
`),
).toEqual(["next reaction", "next catch", "result reaction", "result catch", "return reaction", "return catch"])
})
test("prefers Symbol.asyncIterator over Symbol.iterator", async () => {
expect(
await value(`
let done = false
const asyncIterator = {
next: async () => done ? { done: true } : (done = true, { done: false, value: "async" }),
}
const syncIterator = { next: () => ({ done: true }) }
const iterable = {
[Symbol.asyncIterator]: () => asyncIterator,
[Symbol.iterator]: () => syncIterator,
}
const values = []
for await (const item of iterable) values.push(item)
return values
`),
).toEqual(["async"])
})
test("closes a custom iterator on abrupt loop completion", async () => {
expect(
await value(`
let closed = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => (closed += 1, { done: true }),
}
for await (const item of iterator) break
try {
for await (const item of iterator) throw "stop"
} catch (error) {}
return closed
`),
).toBe(2)
})
test("treats a null iterator return method as absent", async () => {
expect(
await value(`
let count = 0
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: null,
}
for await (const item of iterator) {
count += 1
break
}
return count
`),
).toBe(1)
})
test("a non-callable return method replaces break with TypeError", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("a body throw wins over a non-callable return method", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: true,
}
try {
for await (const item of iterator) throw "body"
} catch (error) {
return error
}
return "missed"
`),
).toBe("body")
})
test("rejects a primitive iterator return result", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => ({ done: false, value: 1 }),
return: async () => null,
}
try {
for await (const item of iterator) break
} catch (error) {
return error.name
}
return "missed"
`),
).toBe("TypeError")
})
test("propagates iterator acquisition failures", async () => {
expect(
await value(`
const iterator = {
[Symbol.asyncIterator]: () => { throw "acquire" },
}
try {
for await (const item of iterator) {}
} catch (error) {
return error
}
return "missed"
`),
).toBe("acquire")
})
test("rejects malformed iterator acquisition methods and results", async () => {
expect(
await value(`
const names = []
const invalid = [
{ [Symbol.asyncIterator]: true },
{ [Symbol.asyncIterator]: () => 1 },
{ [Symbol.asyncIterator]: () => ({ next: true }) },
]
for (const iterator of invalid) {
try {
for await (const item of iterator) {}
} catch (error) {
names.push(error.name)
}
}
return names
`),
).toEqual(["TypeError", "TypeError", "TypeError"])
})
test("preserves iterator protocol keys through object copies", async () => {
expect(
await value(`
let done = false
const iterable = {
plain: true,
[Symbol.asyncIterator]: () => iterable,
next: async () => done ? { done: true } : (done = true, { done: false, value: 1 }),
}
const spread = { ...iterable }
const { plain, ...rest } = iterable
const assigned = Object.assign({}, iterable)
const values = []
for await (const item of spread) values.push(item)
return [
values,
Object.hasOwn(spread, Symbol.asyncIterator),
Object.hasOwn(rest, Symbol.asyncIterator),
Object.hasOwn(assigned, Symbol.asyncIterator),
]
`),
).toEqual([[1], true, true, true])
})
test("rejects malformed iterator protocol results", async () => {
const result = await execute(`
const iterator = {
[Symbol.asyncIterator]: () => iterator,
next: async () => 1,
}
for await (const item of iterator) {}
`)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toContain("Iterator next() result must be an object")
})
test("rejects objects without an iterator protocol method", async () => {
const result = await execute(`for await (const item of { values: [1, 2] }) {}`) const result = await execute(`for await (const item of { values: [1, 2] }) {}`)
expect(result.ok).toBe(false) expect(result.ok).toBe(false)
if (result.ok) return if (result.ok) return
expect(result.error.message).toContain("for await...of requires an array, string, Map, Set, or URLSearchParams") expect(result.error.message).toContain("or custom iterator value")
}) })
}) })
+12
View File
@@ -717,6 +717,18 @@ describe("destructuring assignment", () => {
).toEqual({ first: 1, rest: [2, 3], entry: "a4" }) ).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
}) })
test("excludes computed numeric keys from object rest", async () => {
expect(
await value(`
const { [0]: declared, ...declarationRest } = { 0: "a", 1: "b" }
let assigned
let assignmentRest
;({ [0]: assigned, ...assignmentRest } = { 0: "c", 1: "d" })
return { declared, declarationRest, assigned, assignmentRest }
`),
).toEqual({ declared: "a", declarationRest: { 1: "b" }, assigned: "c", assignmentRest: { 1: "d" } })
})
test("rejects computed keys that are not confined property keys", async () => { test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`) const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number") expect(err.message).toContain("Property key must be a string or number")
+1
View File
@@ -358,6 +358,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم", "zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
"zen.api.error.missingApiKey": "مفتاح API مفقود.", "zen.api.error.missingApiKey": "مفتاح API مفقود.",
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.", "zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
"zen.api.error.requestBlockedByUpstreamProvider": "حظر المزود الخارجي الطلب.",
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}", "تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado", "zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
"zen.api.error.missingApiKey": "Chave de API ausente.", "zen.api.error.missingApiKey": "Chave de API ausente.",
"zen.api.error.invalidApiKey": "Chave de API inválida.", "zen.api.error.invalidApiKey": "Chave de API inválida.",
"zen.api.error.requestBlockedByUpstreamProvider": "Solicitação bloqueada pelo provedor upstream.",
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}", "Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke", "zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
"zen.api.error.missingApiKey": "Manglende API-nøgle.", "zen.api.error.missingApiKey": "Manglende API-nøgle.",
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.", "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
"zen.api.error.requestBlockedByUpstreamProvider": "Anmodningen blev blokeret af upstream-udbyderen.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}", "Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
+1
View File
@@ -365,6 +365,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt", "zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
"zen.api.error.missingApiKey": "Fehlender API-Key.", "zen.api.error.missingApiKey": "Fehlender API-Key.",
"zen.api.error.invalidApiKey": "Ungültiger API-Key.", "zen.api.error.invalidApiKey": "Ungültiger API-Key.",
"zen.api.error.requestBlockedByUpstreamProvider": "Anfrage vom vorgelagerten Anbieter blockiert.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}", "5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
+1
View File
@@ -360,6 +360,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported", "zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
"zen.api.error.missingApiKey": "Missing API key.", "zen.api.error.missingApiKey": "Missing API key.",
"zen.api.error.invalidApiKey": "Invalid API key.", "zen.api.error.invalidApiKey": "Invalid API key.",
"zen.api.error.requestBlockedByUpstreamProvider": "Request blocked by upstream provider.",
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}", "5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado", "zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
"zen.api.error.missingApiKey": "Falta la clave API.", "zen.api.error.missingApiKey": "Falta la clave API.",
"zen.api.error.invalidApiKey": "Clave API inválida.", "zen.api.error.invalidApiKey": "Clave API inválida.",
"zen.api.error.requestBlockedByUpstreamProvider": "El proveedor externo bloqueó la solicitud.",
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}", "Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge", "zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge",
"zen.api.error.missingApiKey": "Clé API manquante.", "zen.api.error.missingApiKey": "Clé API manquante.",
"zen.api.error.invalidApiKey": "Clé API invalide.", "zen.api.error.invalidApiKey": "Clé API invalide.",
"zen.api.error.requestBlockedByUpstreamProvider": "Requête bloquée par le fournisseur en amont.",
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}", "Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Provider {{provider}} non supportato", "zen.api.error.providerNotSupported": "Provider {{provider}} non supportato",
"zen.api.error.missingApiKey": "Chiave API mancante.", "zen.api.error.missingApiKey": "Chiave API mancante.",
"zen.api.error.invalidApiKey": "Chiave API non valida.", "zen.api.error.invalidApiKey": "Chiave API non valida.",
"zen.api.error.requestBlockedByUpstreamProvider": "Richiesta bloccata dal provider upstream.",
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}", "Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
+1
View File
@@ -362,6 +362,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません", "zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません",
"zen.api.error.missingApiKey": "APIキーがありません。", "zen.api.error.missingApiKey": "APIキーがありません。",
"zen.api.error.invalidApiKey": "無効なAPIキーです。", "zen.api.error.invalidApiKey": "無効なAPIキーです。",
"zen.api.error.requestBlockedByUpstreamProvider": "上流プロバイダーによりリクエストがブロックされました。",
"zen.api.error.subscriptionQuotaExceeded": "zen.api.error.subscriptionQuotaExceeded":
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。", "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
+1
View File
@@ -357,6 +357,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다", "zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다",
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.", "zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.", "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
"zen.api.error.requestBlockedByUpstreamProvider": "상위 제공자가 요청을 차단했습니다.",
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.", "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}", "5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
+1
View File
@@ -363,6 +363,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke", "zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke",
"zen.api.error.missingApiKey": "Mangler API-nøkkel.", "zen.api.error.missingApiKey": "Mangler API-nøkkel.",
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.", "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
"zen.api.error.requestBlockedByUpstreamProvider": "Forespørselen ble blokkert av leverandøren.",
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}", "5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
+1
View File
@@ -364,6 +364,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany", "zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany",
"zen.api.error.missingApiKey": "Brak klucza API.", "zen.api.error.missingApiKey": "Brak klucza API.",
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.", "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
"zen.api.error.requestBlockedByUpstreamProvider": "Żądanie zablokowane przez dostawcę zewnętrznego.",
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}", "Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
+1
View File
@@ -368,6 +368,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается", "zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается",
"zen.api.error.missingApiKey": "Отсутствует API ключ.", "zen.api.error.missingApiKey": "Отсутствует API ключ.",
"zen.api.error.invalidApiKey": "Неверный API ключ.", "zen.api.error.invalidApiKey": "Неверный API ключ.",
"zen.api.error.requestBlockedByUpstreamProvider": "Запрос заблокирован вышестоящим провайдером.",
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}", "Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
+1
View File
@@ -359,6 +359,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}", "zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}",
"zen.api.error.missingApiKey": "ไม่มี API key", "zen.api.error.missingApiKey": "ไม่มี API key",
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง", "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
"zen.api.error.requestBlockedByUpstreamProvider": "คำขอถูกบล็อกโดยผู้ให้บริการต้นทาง",
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}", "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}", "ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
+1
View File
@@ -366,6 +366,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor", "zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor",
"zen.api.error.missingApiKey": "API anahtarı eksik.", "zen.api.error.missingApiKey": "API anahtarı eksik.",
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.", "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
"zen.api.error.requestBlockedByUpstreamProvider": "İstek üst sağlayıcı tarafından engellendi.",
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.", "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}", "5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
+1
View File
@@ -363,6 +363,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується", "zen.api.error.providerNotSupported": "Провайдер {{provider}} не підтримується",
"zen.api.error.missingApiKey": "Відсутній ключ API.", "zen.api.error.missingApiKey": "Відсутній ключ API.",
"zen.api.error.invalidApiKey": "Недійсний ключ API.", "zen.api.error.invalidApiKey": "Недійсний ключ API.",
"zen.api.error.requestBlockedByUpstreamProvider": "Запит заблоковано зовнішнім провайдером.",
"zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.", "zen.api.error.subscriptionQuotaExceeded": "Перевищено квоту підписки. Повторіть через {{retryIn}}.",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}", "Досягнуто 5-годинного ліміту використання. Скидається через {{retryIn}}. Щоб продовжити, увімкніть використання з доступного балансу: {{consoleGoUrl}}",
+1
View File
@@ -346,6 +346,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "不支持提供商 {{provider}}", "zen.api.error.providerNotSupported": "不支持提供商 {{provider}}",
"zen.api.error.missingApiKey": "缺少 API 密钥。", "zen.api.error.missingApiKey": "缺少 API 密钥。",
"zen.api.error.invalidApiKey": "无效的 API 密钥。", "zen.api.error.invalidApiKey": "无效的 API 密钥。",
"zen.api.error.requestBlockedByUpstreamProvider": "请求被上游提供商阻止。",
"zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。", "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}", "已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
+1
View File
@@ -346,6 +346,7 @@ export const dict = {
"zen.api.error.providerNotSupported": "不支援供應商 {{provider}}", "zen.api.error.providerNotSupported": "不支援供應商 {{provider}}",
"zen.api.error.missingApiKey": "缺少 API 金鑰。", "zen.api.error.missingApiKey": "缺少 API 金鑰。",
"zen.api.error.invalidApiKey": "無效的 API 金鑰。", "zen.api.error.invalidApiKey": "無效的 API 金鑰。",
"zen.api.error.requestBlockedByUpstreamProvider": "請求遭上游供應商封鎖。",
"zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。", "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
"zen.api.error.goSubscriptionRollingLimitExceeded": "zen.api.error.goSubscriptionRollingLimitExceeded":
"已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}", "已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
@@ -697,6 +697,9 @@ export async function handler(
workspace: { workspace: {
id: WorkspaceTable.id, id: WorkspaceTable.id,
region: WorkspaceTable.region, region: WorkspaceTable.region,
isBlocked: WorkspaceTable.is_blocked,
isFlaggedByAnthropic: WorkspaceTable.is_flagged_by_anthropic,
isFlaggedByOpenAI: WorkspaceTable.is_flagged_by_openai,
}, },
billing: { billing: {
balance: BillingTable.balance, balance: BillingTable.balance,
@@ -772,6 +775,12 @@ export async function handler(
) )
if (!data) throw new AuthError(t("zen.api.error.invalidApiKey")) if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
if (
data.workspace.isBlocked ||
(data.workspace.isFlaggedByAnthropic && modelInfo.id.startsWith("claude-")) ||
(data.workspace.isFlaggedByOpenAI && modelInfo.id.startsWith("gpt-"))
)
throw new AuthError(t("zen.api.error.requestBlockedByUpstreamProvider"))
if ( if (
modelInfo.id.startsWith("alpha-") && modelInfo.id.startsWith("alpha-") &&
Resource.App.stage === "production" && Resource.App.stage === "production" &&
@@ -0,0 +1,3 @@
ALTER TABLE `workspace` ADD `is_blocked` boolean;--> statement-breakpoint
ALTER TABLE `workspace` ADD `is_flagged_by_anthropic` boolean;--> statement-breakpoint
ALTER TABLE `workspace` ADD `is_flagged_by_openai` boolean;
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { boolean, json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types" import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable( export const WorkspaceTable = mysqlTable(
@@ -8,6 +8,9 @@ export const WorkspaceTable = mysqlTable(
slug: varchar("slug", { length: 255 }), slug: varchar("slug", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(), region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(),
is_blocked: boolean(),
is_flagged_by_anthropic: boolean(),
is_flagged_by_openai: boolean(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex("slug").on(table.slug)], (table) => [uniqueIndex("slug").on(table.slug)],
+33
View File
@@ -0,0 +1,33 @@
export * as App from "./app"
import { Context, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export interface Info {
readonly name: string
readonly version: string
readonly channel: string
}
export const Metadata = Context.Reference<Info>("@opencode/App", {
defaultValue: () => make(),
})
export function make(input: Partial<Info> = {}): Info {
return {
name: input.name ?? "opencode",
version: input.version ?? "unknown",
channel: input.channel ?? "unknown",
}
}
export function useragent(app: Info) {
return `opencode/${app.channel}/${app.version}/${app.name}`
}
export const layer = (input?: Partial<Info>) => Layer.succeed(Metadata, make(input))
export const configured = (input?: Partial<Info>) =>
makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] })
export const node = configured()
+80
View File
@@ -0,0 +1,80 @@
export * as CodeMode from "./codemode"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
import { Tools } from "./tool/tools"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: AnyTool
readonly instructions?: string
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<
string,
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
)
}),
)
}),
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
const registrations = new Map<string, ExecuteTool.Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
if (registrations.size === 0) return {}
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
tool: ExecuteTool.create(registrations),
instructions: ExecuteTool.instructions(registrations),
}
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
@@ -0,0 +1,45 @@
export * as CodeModeInstructions from "./instructions"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AgentV2 } from "../agent"
import { CodeMode } from "../codemode"
import { Instructions } from "../instructions/index"
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeModeInstructions") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
return Service.of({
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
const instructions = selection.info
? (yield* codeMode.materialize(selection.info.permissions)).instructions
: undefined
return Instructions.make({
key: Instructions.Key.make("core/codemode"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.succeed(instructions ?? Instructions.removed),
render: {
initial: (current) => current,
changed: (_previous, current) =>
[
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
current,
].join("\n\n"),
removed: () =>
"Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
},
})
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] })
+4 -13
View File
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path" import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration" import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "@opencode-ai/util/installation/version"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
@@ -40,20 +39,12 @@ const databaseLayer = Layer.effect(
}).pipe(Effect.orDie), }).pipe(Effect.orDie),
) )
export function layer(options?: Options) { export function layer(options: Options = { path: ":memory:" }) {
return Layer.suspend(() => { return Layer.suspend(() => {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename }))) const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path) const filename = options.path ?? ":memory:"
if (options?.path) return provide(join(Global.Path.data, options.path)) if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
if ( return provide(join(Global.Path.data, filename))
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return provide(join(Global.Path.data, "opencode.db"))
return provide(
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
)
}) })
} }
+2 -9
View File
@@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", { export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String, path: Schema.String,
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]), reason: Schema.Literal("non_directory_ancestor"),
}) {} }) {}
export interface ExternalDirectoryAuthorization { export interface ExternalDirectoryAuthorization {
@@ -83,7 +83,6 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const locationRoot = yield* fs.realPath(location.directory)
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) { function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
@@ -124,14 +123,8 @@ const layer = Layer.effect(
const lexicallyInternal = FSUtil.contains(location.directory, absolute) const lexicallyInternal = FSUtil.contains(location.directory, absolute)
const resolved = yield* resolvePath(absolute) const resolved = yield* resolvePath(absolute)
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
return yield* new PathError({ path: input.path, reason: "location_escape" })
}
const external = !lexicallyInternal const external = !lexicallyInternal
const resource = external const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
? slash(resolved.canonical)
: slash(path.relative(locationRoot, resolved.canonical) || ".")
const externalDirectory = const externalDirectory =
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
const externalResource = slash(path.join(externalDirectory, "*")) const externalResource = slash(path.join(externalDirectory, "*"))
+4
View File
@@ -2,6 +2,8 @@ import { Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
import { Catalog } from "./catalog" import { Catalog } from "./catalog"
import { CodeMode } from "./codemode"
import { CodeModeInstructions } from "./codemode/instructions"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { Config } from "./config" import { Config } from "./config"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -66,6 +68,7 @@ const locationServiceNodes = [
Pty.node, Pty.node,
Shell.node, Shell.node,
SkillV2.node, SkillV2.node,
CodeMode.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,
InstructionDiscovery.node, InstructionDiscovery.node,
LocationMutation.node, LocationMutation.node,
@@ -77,6 +80,7 @@ const locationServiceNodes = [
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
Image.node, Image.node,
SkillInstructions.node, SkillInstructions.node,
CodeModeInstructions.node,
ReferenceInstructions.node, ReferenceInstructions.node,
InstructionEntry.node, InstructionEntry.node,
Form.node, Form.node,
+3 -2
View File
@@ -12,6 +12,7 @@ import {
ElicitationCompleteNotificationSchema, ElicitationCompleteNotificationSchema,
ElicitRequestSchema, ElicitRequestSchema,
GetPromptResultSchema, GetPromptResultSchema,
type Implementation,
type ElicitRequestFormParams, type ElicitRequestFormParams,
type ElicitRequestParams, type ElicitRequestParams,
type ElicitRequestURLParams, type ElicitRequestURLParams,
@@ -29,7 +30,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect" import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "../config/mcp" import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000 const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -185,6 +185,7 @@ export const connect = Effect.fnUntraced(function* (
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth. // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
authProvider?: OAuthClientProvider, authProvider?: OAuthClientProvider,
elicitation?: ElicitationHandler, elicitation?: ElicitationHandler,
clientInfo: Implementation = { name: "opencode", version: "unknown" },
) { ) {
const transport: Transport = yield* Effect.gen(function* () { const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") { if (config.type === "local") {
@@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* (
}) })
}) })
const client = new Client( const client = new Client(
{ name: "opencode", version: InstallationVersion }, clientInfo,
{ {
capabilities: { capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
+28 -7
View File
@@ -157,7 +157,17 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
export const layer = Layer.effect( export const Options = Schema.Struct({
clientInfo: Schema.optional(
Schema.Struct({
name: Schema.String,
version: Schema.String,
}),
),
})
export type Options = typeof Options.Type
export const layer = (options?: Options) => Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
@@ -498,7 +508,14 @@ export const layer = Layer.effect(
const authProvider = yield* connectProvider(entry) const authProvider = yield* connectProvider(entry)
// List tools as part of connect so a failure here marks the server failed rather than // List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover. // leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe( const result = yield* MCPClient.connect(
name,
entry.config,
location.directory,
authProvider,
elicitation,
options?.clientInfo,
).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
Scope.provide(scope), Scope.provide(scope),
Effect.exit, Effect.exit,
@@ -772,11 +789,15 @@ export const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ export function configured(options?: Options) {
service: Service, return makeLocationNode({
layer, service: Service,
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], layer: layer(options),
}) deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
})
}
export const node = configured()
// Schema `optional` strips undefined-valued properties on encode, so fields can assign // Schema `optional` strips undefined-valued properties on encode, so fields can assign
// optional properties directly instead of conditionally spreading them. // optional properties directly instead of conditionally spreading them.
+4 -5
View File
@@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev" import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Client } from "@opencode-ai/util/client" import { App } from "./app"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Flock } from "@opencode-ai/util/flock" import { Flock } from "@opencode-ai/util/flock"
import { Hash } from "@opencode-ai/util/hash" import { Hash } from "@opencode-ai/util/hash"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -543,7 +542,7 @@ export const layer = (options?: Options) =>
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const client = yield* Client.Name const app = yield* App.Metadata
const http = HttpClient.filterStatusOk( const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe( (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({ HttpClient.retryTransient({
@@ -556,7 +555,7 @@ export const layer = (options?: Options) =>
const source = options?.url || "https://models.dev" const source = options?.url || "https://models.dev"
const fetch = options?.fetch ?? true const fetch = options?.fetch ?? true
const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}` const userAgent = App.useragent(app)
const filepath = path.join( const filepath = path.join(
Global.Path.cache, Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
@@ -660,7 +659,7 @@ export function configured(options?: Options) {
return makeGlobalNode({ return makeGlobalNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [FSUtil.node, EventV2.node, Client.node, httpClient], deps: [FSUtil.node, EventV2.node, App.node, httpClient],
}) })
} }
+2
View File
@@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
@@ -144,6 +145,7 @@ export const node = makeLocationNode({
layer, layer,
deps: [ deps: [
EventV2.node, EventV2.node,
App.node,
AgentV2.node, AgentV2.node,
AISDK.node, AISDK.node,
Catalog.node, Catalog.node,
+3
View File
@@ -2,6 +2,7 @@ export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest" import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { App } from "../app"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk" import { AISDK } from "../aisdk"
@@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T> const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const app = yield* App.Metadata
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
@@ -61,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return { return {
app,
options: {}, options: {},
agent: { agent: {
get: (id) => agents.get(AgentV2.ID.make(id)), get: (id) => agents.get(AgentV2.ID.make(id)),
+1
View File
@@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) {
) )
const context2: Context = { const context2: Context = {
app: host.app,
options: host.options, options: host.options,
agent: { agent: {
get: (id) => run(host.agent.get(id)), get: (id) => run(host.agent.get(id)),
@@ -1,5 +1,5 @@
import os from "os" import os from "os"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../../app"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({
accountId: config.accountId, accountId: config.accountId,
gateway: config.gatewayId, gateway: config.gatewayId,
apiKey: config.apiKey, apiKey: config.apiKey,
options: gatewayOptions(evt.options, metadata), options: gatewayOptions(evt.options, metadata, ctx.app),
} as any) } as any)
const unified = createUnified({ apiKey: config.apiKey }) const unified = createUnified({ apiKey: config.apiKey })
evt.sdk = { evt.sdk = {
@@ -64,7 +64,7 @@ function gatewayMetadata(options: Record<string, unknown>) {
return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
} }
function gatewayOptions(options: Record<string, unknown>, metadata: unknown) { function gatewayOptions(options: Record<string, unknown>, metadata: unknown, app: App.Info) {
return { return {
metadata, metadata,
cacheTtl: options.cacheTtl, cacheTtl: options.cacheTtl,
@@ -72,7 +72,7 @@ function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
skipCache: options.skipCache, skipCache: options.skipCache,
collectLog: options.collectLog, collectLog: options.collectLog,
headers: { headers: {
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`, "User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
}, },
} }
} }
@@ -1,5 +1,5 @@
import os from "os" import os from "os"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../../app"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
@@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({
if (!hasWorkersEndpoint(evt.model) && !accountId) return if (!hasWorkersEndpoint(evt.model) && !accountId) return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible( evt.sdk = mod.createOpenAICompatible(
sdkOptions({ sdkOptions(
...evt.options, {
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), ...evt.options,
}) as any, baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
},
ctx.app,
) as any,
) )
}), }),
) )
@@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: {
return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string" return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
} }
function sdkOptions(options: Record<string, any>) { function sdkOptions(options: Record<string, any>, app: App.Info) {
return { return {
...options, ...options,
baseURL: expandAccountId(options.baseURL), baseURL: expandAccountId(options.baseURL),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: { headers: {
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`, "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
...options.headers, ...options.headers,
}, },
name: providerID, name: providerID,
@@ -4,7 +4,7 @@ import { Catalog } from "../../catalog"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { CopilotModels } from "../../github-copilot/models" import { CopilotModels } from "../../github-copilot/models"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../../app"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal"
const clientID = "Ov23li8tweQw6odWQebz" const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01" const apiVersion = "2026-06-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000 const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device") const methodID = Integration.MethodID.make("device")
@@ -27,10 +28,18 @@ const Token = Schema.Struct({
error: Schema.optional(Schema.String), error: Schema.optional(Schema.String),
interval: Schema.optional(Schema.Number), interval: Schema.optional(Schema.Number),
}) })
const User = Schema.Struct({
endpoints: Schema.optional(
Schema.Struct({
api: Schema.optional(Schema.String),
}),
),
})
const decodeUser = Schema.decodeUnknownOption(User)
const JsonBody = Schema.UnknownFromJsonString const JsonBody = Schema.UnknownFromJsonString
const decodeBody = Schema.decodeUnknownOption(JsonBody) const decodeBody = Schema.decodeUnknownOption(JsonBody)
const oauth = { const oauth = (app: App.Info) => ({
integrationID: Integration.ID.make("github-copilot"), integrationID: Integration.ID.make("github-copilot"),
method: { method: {
id: methodID, id: methodID,
@@ -63,7 +72,7 @@ const oauth = {
const urls = oauthURLs(domain) const urls = oauthURLs(domain)
const device = yield* request(urls.device, { const device = yield* request(urls.device, {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: JSON.stringify({ client_id: clientID, scope: "read:user" }), body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
}).pipe(Effect.map(Schema.decodeUnknownSync(Device))) }).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
const interval = Math.max(device.interval, 1) * 1000 const interval = Math.max(device.interval, 1) * 1000
@@ -71,7 +80,7 @@ const oauth = {
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> => const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
request(urls.token, { request(urls.token, {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: JSON.stringify({ body: JSON.stringify({
client_id: clientID, client_id: clientID,
device_code: device.device_code, device_code: device.device_code,
@@ -81,15 +90,35 @@ const oauth = {
Effect.map(Schema.decodeUnknownSync(Token)), Effect.map(Schema.decodeUnknownSync(Token)),
Effect.flatMap((token) => { Effect.flatMap((token) => {
if (token.access_token) { if (token.access_token) {
return Effect.succeed( const access = token.access_token
Credential.OAuth.make({ return request(
type: "oauth", `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
methodID, {
refresh: token.access_token, headers: {
access: token.access_token, Accept: "application/json",
expires: 0, Authorization: `Bearer ${access}`,
...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}), "User-Agent": App.useragent(app),
}), "X-GitHub-Api-Version": userApiVersion,
},
},
).pipe(
Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")),
Effect.catch(() => Effect.succeed(undefined)),
Effect.map((apiEndpoint) =>
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: access,
access,
expires: 0,
...((enterprise || apiEndpoint) && {
metadata: {
...(enterprise ? { enterpriseUrl: domain } : {}),
...(apiEndpoint ? { apiEndpoint } : {}),
},
}),
}),
),
) )
} }
if (token.error === "authorization_pending") if (token.error === "authorization_pending")
@@ -109,7 +138,7 @@ const oauth = {
callback: poll(interval), callback: poll(interval),
} }
}), }),
} satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
function shouldUseResponses(modelID: string) { function shouldUseResponses(modelID: string) {
// Copilot supports Responses for GPT-5 class models, except mini variants // Copilot supports Responses for GPT-5 class models, except mini variants
@@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({
return return
} }
const enterprise = credential.metadata?.enterpriseUrl loaded.baseURL = copilotBaseURL(credential.metadata)
loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined)
const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot) const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot) const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
loaded.models = yield* Effect.tryPromise({ loaded.models = yield* Effect.tryPromise({
@@ -152,7 +180,7 @@ export const GithubCopilotPlugin = define({
{ {
...provider?.headers, ...provider?.headers,
Authorization: `Bearer ${credential.refresh}`, Authorization: `Bearer ${credential.refresh}`,
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(ctx.app),
"X-GitHub-Api-Version": apiVersion, "X-GitHub-Api-Version": apiVersion,
}, },
existing, existing,
@@ -166,8 +194,9 @@ export const GithubCopilotPlugin = define({
}) })
yield* ctx.integration.transform((draft) => { yield* ctx.integration.transform((draft) => {
draft.method.update(oauth) draft.method.update(oauth(ctx.app))
}) })
yield* load()
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot) const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item) return if (!item) return
@@ -199,7 +228,6 @@ export const GithubCopilotPlugin = define({
Stream.runForEach(refresh), Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
"sdk", "sdk",
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
@@ -209,6 +237,7 @@ export const GithubCopilotPlugin = define({
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined, typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
evt.options.fetch, evt.options.fetch,
evt.package === "@ai-sdk/anthropic", evt.package === "@ai-sdk/anthropic",
ctx.app,
) )
if (evt.package === "@ai-sdk/anthropic") { if (evt.package === "@ai-sdk/anthropic") {
evt.options.headers = { evt.options.headers = {
@@ -261,11 +290,18 @@ function baseURL(enterprise?: string) {
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
} }
function headers() { export function copilotBaseURL(metadata?: Readonly<Record<string, unknown>>) {
const endpoint = metadata?.apiEndpoint
if (typeof endpoint === "string" && endpoint) return endpoint
const enterprise = metadata?.enterpriseUrl
return baseURL(typeof enterprise === "string" ? enterprise : undefined)
}
function headers(app: App.Info) {
return { return {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(app),
} }
} }
@@ -282,7 +318,12 @@ function request(url: string, init: RequestInit) {
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch { export function copilotFetch(
token: string | undefined,
upstream: Fetch | undefined,
anthropic: boolean,
app: App.Info,
): Fetch {
const send = upstream ?? fetch const send = upstream ?? fetch
return async (input, init) => { return async (input, init) => {
const requestHeaders = new Headers(init?.headers) const requestHeaders = new Headers(init?.headers)
@@ -291,7 +332,7 @@ export function copilotFetch(token: string | undefined, upstream: Fetch | undefi
requestHeaders.delete("x-api-key") requestHeaders.delete("x-api-key")
requestHeaders.set("Authorization", `Bearer ${token}`) requestHeaders.set("Authorization", `Bearer ${token}`)
} }
requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`) requestHeaders.set("User-Agent", App.useragent(app))
requestHeaders.set("Openai-Intent", "conversation-edits") requestHeaders.set("Openai-Intent", "conversation-edits")
requestHeaders.set("X-GitHub-Api-Version", apiVersion) requestHeaders.set("X-GitHub-Api-Version", apiVersion)
if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
+2 -2
View File
@@ -1,5 +1,5 @@
import os from "os" import os from "os"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../../app"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
@@ -20,7 +20,7 @@ export const GitLabPlugin = define({
: (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"), : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN, apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
aiGatewayHeaders: { aiGatewayHeaders: {
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, "User-Agent": `${App.useragent(ctx.app)} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
"anthropic-beta": "context-1m-2025-08-07", "anthropic-beta": "context-1m-2025-08-07",
...evt.options.aiGatewayHeaders, ...evt.options.aiGatewayHeaders,
}, },
+24 -22
View File
@@ -2,9 +2,9 @@ import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { App } from "../../app"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page" import { OauthCallbackPage } from "../../oauth/page"
@@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString(
) )
const decodeClaims = Schema.decodeUnknownOption(Claims) const decodeClaims = Schema.decodeUnknownOption(Claims)
const browser = { const browser = (app: App.Info) => ({
integrationID: Integration.ID.make("openai"), integrationID: Integration.ID.make("openai"),
method: { method: {
id: browserMethodID, id: browserMethodID,
@@ -91,15 +91,15 @@ const browser = {
url: authorizeURL(redirect, pkce, state), url: authorizeURL(redirect, pkce, state),
instructions: "Complete authorization in your browser. This window will close automatically.", instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe( callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, redirect, pkce)), Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
Effect.map((tokens) => credential(browserMethodID, tokens)), Effect.map((tokens) => credential(browserMethodID, tokens)),
), ),
} }
}), }),
refresh: (value) => refresh(browserMethodID, value), refresh: (value) => refresh(browserMethodID, value, app),
} satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
const headless = { const headless = (app: App.Info) => ({
integrationID: Integration.ID.make("openai"), integrationID: Integration.ID.make("openai"),
method: { method: {
id: headlessMethodID, id: headlessMethodID,
@@ -112,7 +112,7 @@ const headless = {
`${issuer}/api/accounts/deviceauth/usercode`, `${issuer}/api/accounts/deviceauth/usercode`,
{ {
method: "POST", method: "POST",
headers: headers("application/json"), headers: headers("application/json", app),
body: JSON.stringify({ client_id: clientID }), body: JSON.stringify({ client_id: clientID }),
}, },
) )
@@ -127,7 +127,7 @@ const headless = {
try: (signal) => try: (signal) =>
fetch(`${issuer}/api/accounts/deviceauth/token`, { fetch(`${issuer}/api/accounts/deviceauth/token`, {
method: "POST", method: "POST",
headers: headers("application/json"), headers: headers("application/json", app),
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
signal, signal,
}), }),
@@ -140,10 +140,12 @@ const headless = {
} }
return credential( return credential(
headlessMethodID, headlessMethodID,
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { yield* exchange(
verifier: data.code_verifier, data.authorization_code,
challenge: "", `${issuer}/deviceauth/callback`,
}), { verifier: data.code_verifier, challenge: "" },
app,
),
) )
} }
if (response.status !== 403 && response.status !== 404) { if (response.status !== 403 && response.status !== 404) {
@@ -154,8 +156,8 @@ const headless = {
}), }),
} }
}), }),
refresh: (value) => refresh(headlessMethodID, value), refresh: (value) => refresh(headlessMethodID, value, app),
} satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
export const OpenAIPlugin = define({ export const OpenAIPlugin = define({
id: "opencode.provider.openai", id: "opencode.provider.openai",
@@ -173,8 +175,8 @@ export const OpenAIPlugin = define({
}) })
yield* ctx.integration.transform((draft) => { yield* ctx.integration.transform((draft) => {
draft.method.update(browser) draft.method.update(browser(ctx.app))
draft.method.update(headless) draft.method.update(headless(ctx.app))
}) })
yield* load() yield* load()
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
@@ -232,14 +234,14 @@ export const OpenAIPlugin = define({
}), }),
} satisfies PluginInternal.InternalPlugin) } satisfies PluginInternal.InternalPlugin)
function headers(contentType: string) { function headers(contentType: string, app: App.Info) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } return { "Content-Type": contentType, "User-Agent": App.useragent(app) }
} }
function exchange(code: string, redirect: string, pkce: Pkce) { function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) {
return request<TokenResponse>(`${issuer}/oauth/token`, { return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST", method: "POST",
headers: headers("application/x-www-form-urlencoded"), headers: headers("application/x-www-form-urlencoded", app),
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code, code,
@@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) {
}) })
} }
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) { function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">, app: App.Info) {
return request<TokenResponse>(`${issuer}/oauth/token`, { return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST", method: "POST",
headers: headers("application/x-www-form-urlencoded"), headers: headers("application/x-www-form-urlencoded", app),
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: value.refresh, refresh_token: value.refresh,
+20 -20
View File
@@ -2,8 +2,8 @@ import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect" import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { App } from "../../app"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page" import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
@@ -48,7 +48,7 @@ const DeviceError = Schema.Struct({
}) })
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError)) const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
const browser = { const browser = (app: App.Info) => ({
integrationID: Integration.ID.make("xai"), integrationID: Integration.ID.make("xai"),
method: { method: {
id: browserMethodID, id: browserMethodID,
@@ -108,15 +108,15 @@ const browser = {
url: authorizeURL(pkce, state, randomString(32)), url: authorizeURL(pkce, state, randomString(32)),
instructions: "Complete authorization in your browser. This window will close automatically.", instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe( callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, pkce)), Effect.flatMap((value) => exchange(value, pkce, app)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)), Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
), ),
} }
}), }),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })), refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
} satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
const device = { const device = (app: App.Info) => ({
integrationID: Integration.ID.make("xai"), integrationID: Integration.ID.make("xai"),
method: { method: {
id: deviceMethodID, id: deviceMethodID,
@@ -128,7 +128,7 @@ const device = {
`${issuer}/device/code`, `${issuer}/device/code`,
{ {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: new URLSearchParams({ client_id: clientID, scope }).toString(), body: new URLSearchParams({ client_id: clientID, scope }).toString(),
}, },
Device, Device,
@@ -142,14 +142,14 @@ const device = {
url: value.verification_uri_complete ?? value.verification_uri, url: value.verification_uri_complete ?? value.verification_uri,
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
} }
}), }),
), ),
), ),
), ),
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })), refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
} satisfies IntegrationOAuthMethodRegistration }) satisfies IntegrationOAuthMethodRegistration
export const XAIPlugin = define({ export const XAIPlugin = define({
id: "opencode.provider.xai", id: "opencode.provider.xai",
@@ -158,8 +158,8 @@ export const XAIPlugin = define({
draft.update("xai", (integration) => { draft.update("xai", (integration) => {
integration.name = "xAI" integration.name = "xAI"
}) })
draft.method.update(browser) draft.method.update(browser(ctx.app))
draft.method.update(device) draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
}) })
yield* ctx.aisdk.hook( yield* ctx.aisdk.hook(
@@ -180,12 +180,12 @@ export const XAIPlugin = define({
}), }),
}) })
function exchange(code: string, pkce: Pkce) { function exchange(code: string, pkce: Pkce, app: App.Info) {
return request( return request(
`${issuer}/token`, `${issuer}/token`,
{ {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code, code,
@@ -198,12 +198,12 @@ function exchange(code: string, pkce: Pkce) {
) )
} }
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) { function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) {
return request( return request(
`${issuer}/token`, `${issuer}/token`,
{ {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: value.refresh, refresh_token: value.refresh,
@@ -214,7 +214,7 @@ function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata))) ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
} }
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> { function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, unknown> {
return Effect.gen(function* () { return Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis const started = yield* Clock.currentTimeMillis
const expires = started + positiveSeconds(device.expires_in, 300) * 1000 const expires = started + positiveSeconds(device.expires_in, 300) * 1000
@@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
} }
const response = yield* send(`${issuer}/token`, { const response = yield* send(`${issuer}/token`, {
method: "POST", method: "POST",
headers: headers(), headers: headers(app),
body: new URLSearchParams({ body: new URLSearchParams({
grant_type: deviceGrant, grant_type: deviceGrant,
client_id: clientID, client_id: clientID,
@@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) {
return expiration ? expiration * 1000 : Date.now() + 3600 * 1000 return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
} }
function headers() { function headers(app: App.Info) {
return { return {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json", Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(app),
} }
} }
+7 -6
View File
@@ -2,11 +2,10 @@
export * as SkillPlugin from "./skill" export * as SkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define, type Context } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect" import { Effect } from "effect"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
import { Config } from "../config" import { Config } from "../config"
import { Location } from "../location" import { Location } from "../location"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -27,7 +26,7 @@ const REPORT_DESCRIPTION =
export const Plugin = define({ export const Plugin = define({
id: "opencode.skill", id: "opencode.skill",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics() const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => { yield* ctx.skill.transform((draft) => {
draft.source( draft.source(
SkillV2.EmbeddedSource.make({ SkillV2.EmbeddedSource.make({
@@ -58,7 +57,9 @@ export const Plugin = define({
}), }),
}) })
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () { const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
app: Context["app"],
) {
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"])) const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
return [ return [
ReportContent, ReportContent,
@@ -67,8 +68,8 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
"", "",
"These values were captured when the built-in report skill was registered. Verify them before publishing.", "These values were captured when the built-in report skill was registered. Verify them before publishing.",
"", "",
`- opencode version: ${InstallationVersion}`, `- opencode version: ${app.version}`,
`- install/channel: ${InstallationChannel}`, `- install/channel: ${app.channel}`,
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`, `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
`- Terminal: ${terminal()}`, `- Terminal: ${terminal()}`,
`- Shell: ${shell()}`, `- Shell: ${shell()}`,
+4 -1
View File
@@ -20,6 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { SessionV1 } from "./v1/session" import { SessionV1 } from "./v1/session"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Slug } from "./util/slug" import { Slug } from "./util/slug"
import { ProjectTable } from "./project/sql" import { ProjectTable } from "./project/sql"
import path from "path" import path from "path"
@@ -304,6 +305,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const app = yield* App.Metadata
const database = yield* Database.Service const database = yield* Database.Service
const db = database.db const db = database.db
const events = yield* EventV2.Service const events = yield* EventV2.Service
@@ -351,7 +353,7 @@ const layer = Layer.effect(
const info = SessionV1.SessionInfo.make({ const info = SessionV1.SessionInfo.make({
id: sessionID, id: sessionID,
slug: Slug.create(), slug: Slug.create(),
version: "unknown", version: app.version,
projectID: project.id, projectID: project.id,
parentID: input.parentID, parentID: input.parentID,
directory: location.directory, directory: location.directory,
@@ -1039,5 +1041,6 @@ export const node = makeGlobalNode({
SessionProjector.node, SessionProjector.node,
FSUtil.node, FSUtil.node,
Global.node, Global.node,
App.node,
], ],
}) })
+6 -6
View File
@@ -10,7 +10,7 @@ import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import type { SessionMessage } from "./message" import type { SessionMessage } from "./message"
import { SessionModelHeaders } from "./model-headers" import { SessionModelHeaders } from "./model-headers"
import { Client } from "@opencode-ai/util/client" import { App } from "../app"
import { SessionRunnerModel } from "./runner/model" import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error" import { toSessionError } from "./to-session-error"
@@ -61,7 +61,7 @@ type Settings = {
} }
type Dependencies = { type Dependencies = {
readonly client: string readonly app: App.Info
readonly events: EventV2.Interface readonly events: EventV2.Interface
readonly llm: { readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError> readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -260,7 +260,7 @@ const make = (dependencies: Dependencies) => {
.stream( .stream(
LLM.request({ LLM.request({
model: plan.model, model: plan.model,
http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) }, http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)], messages: [Message.user(plan.prompt)],
tools: [], tools: [],
}), }),
@@ -399,13 +399,13 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const config = yield* Config.Service const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const client = yield* Client.Name const app = yield* App.Metadata
return make({ events, llm, models, config: settings(yield* config.entries()), client }) return make({ events, llm, models, config: settings(yield* config.entries()), app })
}), }),
) )
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node], deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
}) })
+4
View File
@@ -12,6 +12,7 @@ import { McpInstructions } from "../mcp/instructions"
import { PluginSupervisor } from "../plugin/supervisor" import { PluginSupervisor } from "../plugin/supervisor"
import { ReferenceInstructions } from "../reference/instructions" import { ReferenceInstructions } from "../reference/instructions"
import { SkillInstructions } from "../skill/instructions" import { SkillInstructions } from "../skill/instructions"
import { CodeModeInstructions } from "../codemode/instructions"
import { AgentNotFoundError } from "./error" import { AgentNotFoundError } from "./error"
import { SessionHistory } from "./history" import { SessionHistory } from "./history"
import { InstructionEntry } from "./instruction-entry" import { InstructionEntry } from "./instruction-entry"
@@ -54,6 +55,7 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const builtins = yield* InstructionBuiltIns.Service const builtins = yield* InstructionBuiltIns.Service
const codeModeInstructions = yield* CodeModeInstructions.Service
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service const entries = yield* InstructionEntry.Service
@@ -77,6 +79,7 @@ const layer = Layer.effect(
const instructions = yield* Effect.all( const instructions = yield* Effect.all(
[ [
builtins.load(sessionID), builtins.load(sessionID),
codeModeInstructions.load(agent),
discovery.load(), discovery.load(),
skillInstructions.load(agent), skillInstructions.load(agent),
referenceInstructions.load(), referenceInstructions.load(),
@@ -109,6 +112,7 @@ export const node = makeLocationNode({
layer, layer,
deps: [ deps: [
AgentV2.node, AgentV2.node,
CodeModeInstructions.node,
Database.node, Database.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,
InstructionDiscovery.node, InstructionDiscovery.node,
+4 -4
View File
@@ -4,7 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client" import { App } from "../app"
import { llmClient } from "../effect/app-node-platform" import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks" import { PluginHooks } from "../plugin/hooks"
import { SessionContext } from "./context" import { SessionContext } from "./context"
@@ -23,7 +23,7 @@ export const layer = Layer.effect(
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const client = yield* Client.Name const app = yield* App.Metadata
return SessionGenerate.Service.of({ return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) { generate: Effect.fn("SessionGenerate.generate")(function* (input) {
@@ -51,7 +51,7 @@ export const layer = Layer.effect(
return (yield* llm.generate( return (yield* llm.generate(
LLM.request({ LLM.request({
model: model.model, model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, client) }, http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { openai: { promptCacheKey } }, providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system, system: contextEvent.system,
messages: contextEvent.messages, messages: contextEvent.messages,
@@ -67,5 +67,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: SessionGenerate.Service, service: SessionGenerate.Service,
layer, layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient], deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
}) })
+4 -4
View File
@@ -1,14 +1,14 @@
export * as SessionModelHeaders from "./model-headers" export * as SessionModelHeaders from "./model-headers"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../app"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, client: string) => ({ export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
"x-session-affinity": session.id, "x-session-affinity": session.id,
"X-Session-Id": session.id, "X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(app),
"x-opencode-project": session.projectID, "x-opencode-project": session.projectID,
"x-opencode-session": session.id, "x-opencode-session": session.id,
"x-opencode-client": client, "x-opencode-client": app.name,
}) })
+46 -6
View File
@@ -1,10 +1,11 @@
export * as SessionModelRequest from "./model-request" export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client" import { App } from "../app"
import { ModelV2 } from "../model"
import { PluginHooks } from "../plugin/hooks" import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context" import { SessionContext } from "./context"
@@ -27,6 +28,45 @@ interface PrepareInput {
readonly step: number readonly step: number
} }
const mimeToModality = (mime: string) => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
}
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
const modality = mimeToModality(mime)
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
return {
type: "text" as const,
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
}
}
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media") {
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: ToolContent) => {
if (item.type !== "file") return item
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
}),
},
}
}),
}),
)
/** /**
* Builds an outbound model request and captures the tool-call capability that * Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate * must remain paired with it. It does not execute the request or mutate
@@ -45,7 +85,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const client = yield* Client.Name const app = yield* App.Metadata
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
const session = input.context.session const session = input.context.session
@@ -83,11 +123,11 @@ export const layer = Layer.effect(
const request = LLM.request({ const request = LLM.request({
model, model,
http: { http: {
headers: SessionModelHeaders.make(session, client), headers: SessionModelHeaders.make(session, app),
}, },
providerOptions: { openai: { promptCacheKey } }, providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system, system: contextEvent.system,
messages: contextEvent.messages, messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools, tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined, toolChoice: stepLimitReached ? "none" : undefined,
}) })
@@ -117,5 +157,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [PluginHooks.node, ToolRegistry.node, Client.node], deps: [PluginHooks.node, ToolRegistry.node, App.node],
}) })
+14 -3
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */ /** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */ /** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"] readonly cost: ModelV2.Info["cost"]
} }
@@ -96,14 +98,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ export const resolved = (
model: Model,
options: {
readonly capabilities: ModelV2.Capabilities
readonly variant?: ModelV2.VariantID
readonly cost: ModelV2.Info["cost"]
},
): Resolved => ({
model, model,
ref: ModelV2.Ref.make({ ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id), id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider), providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }), ...(options.variant === undefined ? {} : { variant: options.variant }),
}), }),
cost, capabilities: options.capabilities,
cost: options.cost,
}) })
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -359,6 +369,7 @@ const layer = Layer.effect(
providerID: selected.providerID, providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}), }),
capabilities: selected.capabilities,
cost: selected.cost, cost: selected.cost,
} }
}), }),
+6 -6
View File
@@ -6,7 +6,7 @@ import { AgentV2 } from "../agent"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Client } from "@opencode-ai/util/client" import { App } from "../app"
import { llmClient } from "../effect/app-node-platform" import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionHistory } from "./history" import { SessionHistory } from "./history"
@@ -18,7 +18,7 @@ import { SessionUsage } from "./usage"
const MAX_LENGTH = 100 const MAX_LENGTH = 100
type Dependencies = { type Dependencies = {
readonly client: string readonly app: App.Info
readonly events: EventV2.Interface readonly events: EventV2.Interface
readonly llm: { readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError> readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -68,7 +68,7 @@ const make = (dependencies: Dependencies) => {
.stream( .stream(
LLM.request({ LLM.request({
model: resolved.model, model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.client) }, http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system, system: agent.system,
messages: [Message.user(firstUser.text)], messages: [Message.user(firstUser.text)],
tools: [], tools: [],
@@ -112,8 +112,8 @@ export const layer = Layer.effect(
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service const database = yield* Database.Service
const client = yield* Client.Name const app = yield* App.Metadata
const title = make({ events, llm, agents, models, client }) const title = make({ events, llm, agents, models, app })
return Service.of({ return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
}) })
@@ -123,5 +123,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node], deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, App.node],
}) })
+34 -21
View File
@@ -36,34 +36,23 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type> readonly files: Array<typeof ExecuteFile.Type>
} }
interface Registration { export interface Registration {
readonly tool: AnyTool readonly tool: AnyTool
readonly name: string readonly name: string
readonly namespace?: string readonly namespace?: string
} }
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime through { code }.",
"Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.",
"Use `search({ query })` to discover exact signatures when needed.",
"Await important calls and use `Promise.all` for independent calls.",
].join("\n")
export const create = (registrations: ReadonlyMap<string, Registration>) => { export const create = (registrations: ReadonlyMap<string, Registration>) => {
const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = value
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
const discovery = runtime(() => Effect.fail(toolError("Execute context is unavailable")))
return make({ return make({
description: discovery.instructions(), description,
input: CodeMode.Input, input: CodeMode.Input,
output: ExecuteOutput, output: ExecuteOutput,
structured: ExecuteMetadata, structured: ExecuteMetadata,
@@ -92,6 +81,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
), ),
) )
const result = yield* runtime( const result = yield* runtime(
registrations,
(name, registration, input) => (name, registration, input) =>
Effect.gen(function* () { Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@@ -141,6 +131,29 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
}) })
} }
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
}
function runtime(
registrations: ReadonlyMap<string, Registration>,
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function displayInput(input: unknown): Record<string, unknown> | undefined { function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input } if (typeof input !== "object" || Array.isArray(input)) return { input }
+1
View File
@@ -189,6 +189,7 @@ export const Plugin = {
catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }), catch: (error) => new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
}) })
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) { if (moveTarget?.externalDirectory) {
yield* permission.assert({ yield* permission.assert({
action: "external_directory", action: "external_directory",
+18 -15
View File
@@ -9,7 +9,7 @@ import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store" import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard" import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute" import { CodeMode } from "../codemode"
import { import {
definition, definition,
permission, permission,
@@ -74,6 +74,7 @@ const registryLayer = Layer.effect(
const resources = yield* ToolOutputStore.Service const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service const toolHooks = yield* ToolHooks.Service
const image = yield* Image.Service const image = yield* Image.Service
const codeMode = yield* CodeMode.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size" type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) { const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
@@ -111,7 +112,6 @@ const registryLayer = Layer.effect(
readonly tool: AnyTool readonly tool: AnyTool
readonly name: string readonly name: string
readonly namespace?: string readonly namespace?: string
readonly codemode: boolean
} }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>() const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const registrationLock = Semaphore.makeUnsafe(1) const registrationLock = Semaphore.makeUnsafe(1)
@@ -212,15 +212,22 @@ const registryLayer = Layer.effect(
return yield* Effect.fail( return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
) )
return { entries, codemode } return { tools, options, entries, codemode }
}), }),
) )
if (planned.every((plan) => plan.entries.length === 0)) return // CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
yield* Effect.forEach(
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
(plan) => codeMode.register(plan.tools, plan.options),
{ discard: true },
)
const direct = planned.filter((plan) => !plan.codemode)
if (direct.every((plan) => plan.entries.length === 0)) return
yield* Effect.uninterruptible( yield* Effect.uninterruptible(
registrationLock.withPermit( registrationLock.withPermit(
Effect.gen(function* () { Effect.gen(function* () {
const token = {} const token = {}
for (const { entries, codemode } of planned) for (const { entries } of direct)
for (const entry of entries) for (const entry of entries)
local.set(entry.key, [ local.set(entry.key, [
...(local.get(entry.key) ?? []), ...(local.get(entry.key) ?? []),
@@ -230,14 +237,13 @@ const registryLayer = Layer.effect(
tool: entry.tool, tool: entry.tool,
name: entry.name, name: entry.name,
namespace: entry.namespace, namespace: entry.namespace,
codemode,
}, },
}, },
]) ])
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
registrationLock.withPermit( registrationLock.withPermit(
Effect.sync(() => { Effect.sync(() => {
for (const { entries } of planned) for (const { entries } of direct)
for (const entry of entries) { for (const entry of entries) {
const registrations = const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? [] local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
@@ -265,19 +271,16 @@ const registryLayer = Layer.effect(
registerBatch, registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) => materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
registrationLock.withPermit( registrationLock.withPermit(
Effect.sync(() => { Effect.gen(function* () {
const direct = new Map<string, Registration>() const direct = new Map<string, Registration>()
const codemode = new Map<string, Registration>()
const rules = permissions ?? [] const rules = permissions ?? []
for (const [name, entries] of local) { for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration const registration = entries.at(-1)?.registration
if (!registration) continue if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (registration.codemode) codemode.set(name, registration) direct.set(name, registration)
else direct.set(name, registration)
} }
const execute = const execute = (yield* codeMode.materialize(permissions)).tool
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
return { return {
definitions: [ definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
@@ -315,11 +318,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [ToolOutputStore.node, ToolHooks.node, Image.node], deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
}) })
export const toolsNode = makeLocationNode({ export const toolsNode = makeLocationNode({
service: Tools.Service, service: Tools.Service,
layer, layer,
deps: [ToolOutputStore.node, ToolHooks.node, Image.node], deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
}) })
+5 -1
View File
@@ -185,7 +185,11 @@ export const Plugin = {
const run = Effect.gen(function* () { const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input. // The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false }) yield* runtime.session.prompt({
sessionID: child.id,
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
resume: false,
})
yield* runtime.session.resume(child.id) yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id) return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
+2 -2
View File
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Context, Duration, Effect, Layer, Schema } from "effect" import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "../app"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
@@ -241,7 +241,7 @@ export const Plugin = {
// V2 invocation context does not safely expose the model yet. // V2 invocation context does not safely expose the model yet.
}, },
{ {
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(ctx.app),
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
}, },
) )
+8
View File
@@ -0,0 +1,8 @@
import { expect, test } from "bun:test"
import { App } from "@opencode-ai/core/app"
test("formats app metadata as a user agent", () => {
expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe(
"opencode/beta/1.2.3/sdk",
)
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect } from "bun:test"
import { CodeMode } from "@opencode-ai/core/codemode"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Tool } from "@opencode-ai/core/tool/tool"
import { Effect, Schema } from "effect"
import { it } from "./lib/effect"
describe("CodeMode", () => {
it.effect("owns registrations, execute, and catalog materialization", () =>
Effect.gen(function* () {
const codeMode = yield* CodeMode.Service
yield* codeMode.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.String,
execute: ({ text }) => Effect.succeed(text),
}),
})
const materialized = yield* codeMode.materialize()
expect(materialized.tool).toBeDefined()
expect(materialized.instructions).toContain("Echo text")
expect(materialized.instructions).toContain("tools.echo(input:")
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
)
})
@@ -0,0 +1,49 @@
import { describe, expect } from "bun:test"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CodeMode } from "@opencode-ai/core/codemode"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build")))
describe("CodeModeInstructions", () => {
it.effect("renders catalog changes and removal", () => {
let catalog: string | undefined = "Initial Code Mode catalog"
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
[
CodeMode.node,
Layer.mock(CodeMode.Service, {
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }),
register: () => Effect.void,
}),
],
])
return Effect.gen(function* () {
const instructions = yield* CodeModeInstructions.Service
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
expect(initialized.text).toBe("Initial Code Mode catalog")
catalog = "Updated Code Mode catalog"
expect(
yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
})
catalog = undefined
expect(
yield* instructions
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
).toMatchObject({
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
})
}).pipe(Effect.provide(layer))
})
})
@@ -39,12 +39,13 @@ describe("resource", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco" "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
expect(resource("cli").attributes).toMatchObject({ const app = { client: "cli", version: "1.2.3", channel: "beta" }
expect(resource(app).attributes).toMatchObject({
"opencode.client": "cli", "opencode.client": "cli",
"service.namespace": "anomalyco", "service.namespace": "anomalyco",
}) })
expect(resource("cli").attributes["service.instance.id"]).not.toBe("override") expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
}) })
}) })
+8 -6
View File
@@ -77,7 +77,7 @@ describe("LocationMutation", () => {
), ),
) )
it.live("rejects a prospective target below an escaping symlink ancestor", () => it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
withTmp((directory) => { withTmp((directory) => {
const outside = `${directory}-outside` const outside = `${directory}-outside`
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -86,10 +86,12 @@ describe("LocationMutation", () => {
await fs.mkdir(outside) await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape")) await fs.symlink(outside, path.join(directory, "escape"))
}) })
const error = yield* Effect.flip( const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
(yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }), expect(target).toMatchObject({
) canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" }) resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true })) yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory)) }).pipe(provide(directory))
}), }),
@@ -106,7 +108,7 @@ describe("LocationMutation", () => {
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({ expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"), canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
resource: "actual/new.txt", resource: "linked/new.txt",
}) })
}).pipe(provide(directory)), }).pipe(provide(directory)),
), ),
+12 -4
View File
@@ -136,6 +136,7 @@ function resourceServer(
return { return {
state, state,
url: http.url.toString(), url: http.url.toString(),
clientVersion: () => protocol.getClientVersion(),
sendResourceListChanged: () => protocol.sendResourceListChanged(), sendResourceListChanged: () => protocol.sendResourceListChanged(),
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(), completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
close: async () => { close: async () => {
@@ -151,10 +152,11 @@ function resourceServer(
function resourceMcpLayer( function resourceMcpLayer(
server: string | typeof ConfigMCP.Server.Type, server: string | typeof ConfigMCP.Server.Type,
onFormCreated?: (form: Form.Info) => Effect.Effect<void>, onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
options?: MCP.Options,
) { ) {
const directory = AbsolutePath.make(import.meta.dir) const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service") const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe( return MCP.layer(options).pipe(
Layer.provideMerge(Form.layer), Layer.provideMerge(Form.layer),
Layer.provide( Layer.provide(
Layer.mergeAll( Layer.mergeAll(
@@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => {
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
], ],
}) })
}).pipe(Effect.provide(resourceMcpLayer(server.url))) expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
}).pipe(
Effect.provide(
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
),
)
}), }),
), ),
) )
@@ -767,9 +774,10 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute") yield* waitForTool(registry, "execute")
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute") const materialized = yield* registry.materialize()
const execute = materialized.definitions.find((tool) => tool.name === "execute")
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>") expect(execute?.description).not.toContain("tools.demo.search")
}), }),
) )
+1 -1
View File
@@ -302,7 +302,7 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state) const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1) expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json") expect(final.calls[0].url).toContain("/api.json")
expect(final.calls[0].userAgent).toContain("/cli") expect(final.calls[0].userAgent).toContain("/opencode")
}), }),
) )
+47
View File
@@ -40,6 +40,18 @@ describe("Patch", () => {
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow( expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'", "The last line of the patch must be '*** End Patch'",
) )
expect(() => parse("extra\n*** Begin Patch\n*** End Patch")).toThrow(
"The first line of the patch must be '*** Begin Patch'",
)
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nextra")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("allows whitespace after the end marker", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\n \t\n")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
}) })
test("strips a heredoc wrapper", () => { test("strips a heredoc wrapper", () => {
@@ -169,6 +181,41 @@ describe("Patch", () => {
).toBe('He said "hi"\n') ).toBe('He said "hi"\n')
}) })
test("matches Unicode minus signs and spaces", () => {
expect(
Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value 1\n")
.content,
).toBe("value - 2\n")
const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"]
spaces.forEach(
(space) => {
expect(
Patch.derive(
"spaces.txt",
[{ oldLines: ["hello world"], newLines: ["hello there"] }],
`hello${space}world\n`,
).content,
).toBe("hello there\n")
},
)
})
test("does not normalize ellipses", () => {
expect(() =>
Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
).toThrow("Failed to find expected lines")
})
test("prefers a later exact match over an earlier normalized match", () => {
expect(
Patch.derive(
"quotes.txt",
[{ oldLines: ['He said "hello"'], newLines: ['He said "goodbye"'] }],
'He said “hello”\nmiddle\nHe said "hello"\n',
).content,
).toBe('He said “hello”\nmiddle\nHe said "goodbye"\n')
})
test("matches EOF-anchored chunks from the end", () => { test("matches EOF-anchored chunks from the end", () => {
expect( expect(
Patch.derive( Patch.derive(
+1
View File
@@ -19,6 +19,7 @@ type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
export function host(overrides: Overrides = {}): PluginContext { export function host(overrides: Overrides = {}): PluginContext {
return { return {
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
options: {}, options: {},
agent: overrides.agent ?? { agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"), get: () => Effect.die("unused agent.get"),
@@ -1,11 +1,13 @@
import { AISDK } from "@opencode-ai/core/aisdk" import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test" import { App } from "@opencode-ai/core/app"
import { Effect } from "effect" import { describe, expect, test } from "bun:test"
import { Effect, Fiber } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
@@ -14,11 +16,20 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () { const addPlugin = Effect.fn(function* (credential?: Credential.OAuth) {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host) yield* GithubCopilotPlugin.effect({
...host,
integration: {
...host.integration,
connection: {
active: () => Effect.succeed(credential ? ({ type: "env", name: "TEST" } as const) : undefined),
resolve: () => Effect.succeed(credential),
},
},
})
}) })
function required<T>(value: T | undefined): T { function required<T>(value: T | undefined): T {
@@ -40,6 +51,15 @@ function fakeSelectorSdk(calls: string[]) {
} }
describe("GithubCopilotPlugin", () => { describe("GithubCopilotPlugin", () => {
test("prefers the account-specific Copilot API endpoint", () => {
expect(
copilotBaseURL({
enterpriseUrl: "company.ghe.com",
apiEndpoint: "https://api.business.githubcopilot.com",
}),
).toBe("https://api.business.githubcopilot.com")
})
it.effect("registers GitHub Copilot device OAuth", () => it.effect("registers GitHub Copilot device OAuth", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* addPlugin() yield* addPlugin()
@@ -52,6 +72,69 @@ describe("GithubCopilotPlugin", () => {
}), }),
) )
it.live("waits for initial Copilot models before exposing the connected catalog", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const server = Bun.serve({
port: 0,
fetch: async () => {
requested.resolve()
await release.promise
return Response.json({
data: [
{
model_picker_enabled: true,
id: "gpt-5",
name: "GPT-5",
version: "gpt-5-2026-06-01",
supported_endpoints: ["/responses"],
capabilities: {
family: "gpt",
limits: { max_output_tokens: 16_384, max_prompt_tokens: 180_000 },
supports: { tool_calls: true },
},
},
],
})
},
})
return { release, requested, server }
}),
({ release, requested, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(ProviderV2.ID.githubCopilot, () => {})
draft.model.update(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale"), () => {})
})
const credential = Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "access",
refresh: "refresh",
expires: 0,
metadata: { apiEndpoint: server.url.origin },
})
const plugin = yield* addPlugin(credential).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => requested.promise)
const pending = plugin.pollUnsafe() === undefined
release.resolve()
yield* Fiber.join(plugin)
expect(pending).toBe(true)
expect(yield* catalog.model.get(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale"))).toBeUndefined()
expect(yield* catalog.model.get(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5"))).toBeDefined()
}),
({ release, server }) => {
release.resolve()
return Effect.promise(() => server.stop(true))
},
),
)
it.live("adds Copilot authentication and request metadata headers", () => it.live("adds Copilot authentication and request metadata headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const requests: Headers[] = [] const requests: Headers[] = []
@@ -62,6 +145,7 @@ describe("GithubCopilotPlugin", () => {
return Response.json({ ok: true }) return Response.json({ ok: true })
}, },
false, false,
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
) )
yield* Effect.promise(() => yield* Effect.promise(() =>
send("https://api.githubcopilot.com/chat/completions", { send("https://api.githubcopilot.com/chat/completions", {
@@ -77,6 +161,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.get("x-initiator")).toBe("user") expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true") expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01") expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}), }),
) )
+3 -2
View File
@@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect" import { Effect } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
@@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => {
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect( yield* SkillPlugin.Plugin.effect(
host({ host({
app: { name: "test", version: "1.2.3", channel: "beta" },
skill: { skill: {
list: () => Effect.die("unused skill.list"), list: () => Effect.die("unused skill.list"),
transform: skill.transform, transform: skill.transform,
@@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => {
}), }),
) )
expect(report?.slash).toBe(true) expect(report?.slash).toBe(true)
expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`) expect(report?.content).toContain("- opencode version: 1.2.3")
expect(report?.content).toContain("- install/channel: beta")
}), }),
) )
}) })
+8 -1
View File
@@ -49,7 +49,14 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"), generate: () => Effect.die("unused"),
}) })
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const locations = Layer.effect( const locations = Layer.effect(
LocationServiceMap.Service, LocationServiceMap.Service,
LayerMap.make( LayerMap.make(
+10 -4
View File
@@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
@@ -68,7 +68,13 @@ const client = Layer.mock(LLMClient.Service)({
}) })
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@@ -186,10 +192,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-session-affinity": sessionID, "x-session-affinity": sessionID,
"X-Session-Id": sessionID, "X-Session-Id": sessionID,
"x-parent-session-id": parentID, "x-parent-session-id": parentID,
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global, "x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID, "x-opencode-session": sessionID,
"x-opencode-client": "cli", "x-opencode-client": "opencode",
}) })
expect(requests[0]?.generation).toBeUndefined() expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
+8 -1
View File
@@ -65,7 +65,14 @@ const client = Layer.mock(LLMClient.Service)({
return response return response
}), }),
}) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const builtins = Layer.mock(InstructionBuiltIns.Service, { const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () => load: () =>
Effect.succeed( Effect.succeed(
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
[
Message.user([
Message.text("Describe this image"),
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
]),
],
capabilities(["text"]),
)
expect(messages[0]?.content).toEqual([
Message.text("Describe this image"),
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
])
})
test("replaces unsupported media nested in tool results", () => {
const messages = unsupportedParts(
[
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
],
},
}),
),
],
capabilities(["text"]),
)
expect(messages[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{
type: "text",
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
},
],
},
})
})
test("preserves supported media", () => {
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
@@ -73,7 +73,14 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 }, generation: { maxTokens: 20, temperature: 0 },
}) })
.model({ id: "gpt-4o-mini" }) .model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model))) const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
@@ -481,6 +481,9 @@ describe("ToolRegistry", () => {
}) })
.pipe(Scope.provide(scope)) .pipe(Scope.provide(scope))
const materialized = yield* service.materialize() const materialized = yield* service.materialize()
const execute = materialized.definitions.find((tool) => tool.name === "execute")
expect(execute?.description).toContain("confined Code Mode runtime")
expect(execute?.description).not.toContain("Echo text")
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
yield* service.register({ yield* service.register({
echo: Tool.make({ echo: Tool.make({
+8 -7
View File
@@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "@opencode-ai/core/app"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
@@ -283,10 +283,11 @@ let currentModel = model
const models = SessionRunnerModel.layerWith((session) => const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe( modelResolveHook.pipe(
Effect.as( Effect.as(
SessionRunnerModel.resolved( SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
session.model?.id === "replacement" ? replacementModel : currentModel, capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
session.model?.variant, cost: [],
), variant: session.model?.variant,
}),
), ),
), ),
) )
@@ -3179,10 +3180,10 @@ describe("SessionRunnerLLM", () => {
expect(requests[0]?.http?.headers).toEqual({ expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID, "x-session-affinity": sessionID,
"X-Session-Id": sessionID, "X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global, "x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID, "x-opencode-session": sessionID,
"x-opencode-client": "cli", "x-opencode-client": "opencode",
}) })
}), }),
) )
+10 -4
View File
@@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { InstallationVersion } from "@opencode-ai/util/installation/version" import { App } from "@opencode-ai/core/app"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect" import { Effect, Layer, Stream } from "effect"
@@ -65,7 +65,13 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"), generate: () => Effect.die("unused"),
}) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost,
}),
),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@@ -149,10 +155,10 @@ it.effect("generates a title from the sole user message and renames the session"
expect(requests[0]?.http?.headers).toEqual({ expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID, "x-session-affinity": sessionID,
"X-Session-Id": sessionID, "X-Session-Id": sessionID,
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global, "x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID, "x-opencode-session": sessionID,
"x-opencode-client": "cli", "x-opencode-client": "opencode",
}) })
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID) const renamed = yield* store.get(sessionID)
+35
View File
@@ -199,6 +199,41 @@ describe("EditTool", () => {
), ),
) )
it.live("edits an external symlink target with only its in-location permission", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
if (process.platform === "win32") return Effect.void
const target = path.join(outside.path, "external.txt")
const link = path.join(active.path, "link.txt")
return Effect.promise(async () => {
await fs.writeFile(target, "before")
await fs.symlink(target, link)
}).pipe(
Effect.andThen(
withTool(active.path, (registry) =>
executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })),
),
),
Effect.andThen((result) =>
Effect.sync(() => {
expect(result.type).toBe("text")
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["link.txt"])
}),
),
Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves an explicit external absolute path before edit", () => it.live("approves an explicit external absolute path before edit", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),

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