Compare commits

...

13 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
169 changed files with 6879 additions and 1700 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
+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,
+9 -5
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 { InstallationChannel, 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,7 +69,11 @@ 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,
@@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: { database: {
path: path:
process.env.OPENCODE_DB ?? process.env.OPENCODE_DB ??
(["latest", "beta", "prod"].includes(InstallationChannel) || (["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db" ? "opencode.db"
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.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,
@@ -173,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"
+2 -2
View File
@@ -131,7 +131,7 @@ 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" } draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
}) })
}), }),
) )
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({ expect(config).toEqual({
animations: true, animations: true,
prompt: { paste: "compact" }, prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" }, 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 {
+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",
+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] })
+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 -2
View File
@@ -20,7 +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 { InstallationVersion } from "@opencode-ai/util/installation/version" 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"
@@ -305,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
@@ -352,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: InstallationVersion, version: app.version,
projectID: project.id, projectID: project.id,
parentID: input.parentID, parentID: input.parentID,
directory: location.directory, directory: location.directory,
@@ -1040,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,
}) })
+4 -4
View File
@@ -4,7 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@op
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 { ModelV2 } from "../model"
import { PluginHooks } from "../plugin/hooks" import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool/registry"
@@ -85,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
@@ -123,7 +123,7 @@ 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,
@@ -157,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],
}) })
+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 }
+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")
}), }),
) )
+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")
}), }),
) )
}) })
@@ -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"
@@ -192,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.")
@@ -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({
+3 -3
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"
@@ -3180,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",
}) })
}), }),
) )
+3 -3
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"
@@ -155,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()])),
+3
View File
@@ -273,6 +273,9 @@ describe("SubagentTool", () => {
agent: "reviewer", agent: "reviewer",
model: childModel, model: childModel,
}) })
expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
"You are a subagent spawned by another session.\nreview this",
)
const fallback = yield* settleTool(registry, { const fallback = yield* settleTool(registry, {
sessionID: parent.id, sessionID: parent.id,
+33
View File
@@ -220,6 +220,39 @@ describe("WriteTool", () => {
), ),
) )
it.live("writes 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", content: "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()])),
+5
View File
@@ -0,0 +1,5 @@
export interface App {
readonly name: string
readonly version: string
readonly channel: string
}
+2
View File
@@ -1,6 +1,7 @@
import type { PluginApi } from "@opencode-ai/client/effect/api" import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect" import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
@@ -13,6 +14,7 @@ import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js" import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly app: App
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
+2
View File
@@ -1,5 +1,6 @@
import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
@@ -12,6 +13,7 @@ import type { SkillDomain } from "./skill.js"
import type { ToolDomain } from "./tool.js" import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly app: App
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
+1 -1
View File
@@ -11,7 +11,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
ManagedRuntime.make( ManagedRuntime.make(
createEmbeddedRoutes({ createEmbeddedRoutes({
...options, ...options,
client: options.client ?? "sdk", app: { ...options.app, name: options.app?.name ?? "sdk" },
database: { path: ":memory:", ...options.database }, database: { path: ":memory:", ...options.database },
}).pipe(Layer.provide(HttpServer.layerServices)), }).pipe(Layer.provide(HttpServer.layerServices)),
), ),
+21
View File
@@ -25,6 +25,27 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) => const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) }) fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
it.live("exposes app metadata to plugins", () =>
withEmbedded("opencode-embedded-app-", (fixture) =>
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.create({
app: { name: "test", version: "1.2.3", channel: "beta" },
})
const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>()
yield* opencode.plugin({
id: `app-${crypto.randomUUID()}`,
effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid),
})
yield* opencode.plugin.list({ location: location(fixture) })
expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({
name: "test",
version: "1.2.3",
channel: "beta",
})
}),
),
)
it.live( it.live(
"reloads every booted Location after SDK plugin registration", "reloads every booted Location after SDK plugin registration",
() => () =>
+6
View File
@@ -12,6 +12,12 @@ import { createClient } from "@hey-api/openapi-ts"
const opencode = path.resolve(dir, "../../opencode") const opencode = path.resolve(dir, "../../opencode")
const client = path.resolve(dir, "../../client") const client = path.resolve(dir, "../../client")
if (!(await Bun.file(path.join(opencode, "package.json")).exists())) {
await $`rm -rf dist`
await $`bun tsc`
process.exit(0)
}
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
await $`bun -e ${` await $`bun -e ${`
import { OpenApi } from "effect/unstable/httpapi" import { OpenApi } from "effect/unstable/httpapi"
+8 -5
View File
@@ -1,15 +1,18 @@
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers handlers
.handle("health.get", () => .handle("health.get", () =>
Effect.succeed({ Effect.gen(function* () {
healthy: true as const, const info = yield* ServerInfo.Service
version: InstallationVersion, return {
pid: process.pid, healthy: true as const,
version: info.app.version ?? "unknown",
pid: process.pid,
}
}), }),
) )
.handle("health.stop", () => Effect.succeed({ accepted: false })), .handle("health.stop", () => Effect.succeed({ accepted: false })),
+7 -1
View File
@@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability"
import { Schema } from "effect" import { Schema } from "effect"
export const ServerOptions = Schema.Struct({ export const ServerOptions = Schema.Struct({
client: Schema.optional(Schema.String), app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
hostname: Schema.optional(Schema.String), hostname: Schema.optional(Schema.String),
port: Schema.optional( port: Schema.optional(
Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)), Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)),
+7 -6
View File
@@ -1,7 +1,6 @@
export * as ServerProcess from "./process" export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
@@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
// Request fibers may continue inbound trace context, but must not inherit the server startup parent. // Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http yield* bound.http
.serve( .serve(
dispatch(password, status, application, shutdown).pipe( dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }), HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
), ),
HttpMiddleware.logger, HttpMiddleware.logger,
@@ -153,6 +152,7 @@ function dispatch(
status: Status.Interface, status: Status.Interface,
application: Ref.Ref<Option.Option<App>>, application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>, shutdown: Deferred.Deferred<void>,
version: string,
): App { ): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" }) const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -166,7 +166,7 @@ function dispatch(
: undefined : undefined
if (lifecycle !== undefined) { if (lifecycle !== undefined) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized() if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void)) return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
} }
const state = yield* status.current const state = yield* status.current
const app = yield* Ref.get(application) const app = yield* Ref.get(application)
@@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* (
route: "health" | "stop", route: "health" | "stop",
status: Status.Interface, status: Status.Interface,
stop: () => void, stop: () => void,
version: string,
) { ) {
if (route === "health") return yield* healthResponse(status) if (route === "health") return yield* healthResponse(status, version)
const body = yield* request.json.pipe(Effect.option) const body = yield* request.json.pipe(Effect.option)
const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none() const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 }) if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
@@ -210,10 +211,10 @@ const control = Effect.fnUntraced(function* (
return HttpServerResponse.jsonUnsafe({ accepted }) return HttpServerResponse.jsonUnsafe({ accepted })
}) })
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) { const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
const state = yield* status.current const state = yield* status.current
return HttpServerResponse.jsonUnsafe( return HttpServerResponse.jsonUnsafe(
{ healthy: true, version: InstallationVersion, pid: process.pid }, { healthy: true, version, pid: process.pid },
{ {
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503, status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined, headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
+22 -9
View File
@@ -1,4 +1,5 @@
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { App } from "@opencode-ai/core/app"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -6,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventLogger } from "@opencode-ai/core/event-logger" import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Observability } from "@opencode-ai/util/observability" import { Observability } from "@opencode-ai/util/observability"
import { Client } from "@opencode-ai/util/client"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { CommandV2 } from "@opencode-ai/core/command" import { CommandV2 } from "@opencode-ai/core/command"
@@ -15,12 +15,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty" import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { Shell } from "@opencode-ai/core/shell" import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job" import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -88,7 +85,7 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)], [Database.node, Database.configured(options.database)],
[Client.node, Client.configured(options.client)], [App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)], [ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
@@ -105,6 +102,15 @@ function makeRoutes<AuthError, AuthServices>(
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })], [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })], [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })], [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
] ]
@@ -112,7 +118,7 @@ function makeRoutes<AuthError, AuthServices>(
? Layer.unwrap( ? Layer.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements() const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
}), }),
) )
@@ -123,7 +129,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context) const services = Layer.succeedContext(context)
const requestServices = Layer.merge( const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
ServerInfo.layer(serviceURLs), ServerInfo.layer(serviceURLs, options.app),
) )
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(handlers.pipe(Layer.provide(services))),
@@ -133,7 +139,14 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(authorizationLayer), Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer), Layer.provide(schemaErrorLayer),
Layer.provide(auth), Layer.provide(auth),
Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))), Layer.provide(
Observability.layer({
...options.observability,
client: options.app?.name,
version: options.app?.version,
channel: options.app?.channel,
}),
),
HttpRouter.provideRequest(requestServices), HttpRouter.provideRequest(requestServices),
Layer.provideMerge(services), Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer), Layer.provideMerge(HttpRouter.layer),
+7 -5
View File
@@ -1,12 +1,14 @@
import { Context, Layer } from "effect" import { Context, Layer } from "effect"
import { networkInterfaces } from "node:os" import { networkInterfaces } from "node:os"
import type { ServerOptions } from "./options"
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()( export class Service extends Context.Service<
"@opencode-ai/server/ServerInfo", Service,
) {} { readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
>()("@opencode-ai/server/ServerInfo") {}
export function layer(urls: () => ReadonlyArray<string>) { export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
return Layer.succeed(Service, Service.of({ urls })) return Layer.succeed(Service, Service.of({ urls, app }))
} }
export function connectionURLs(value: string, requestedHostname?: string) { export function connectionURLs(value: string, requestedHostname?: string) {
+6
View File
@@ -12,3 +12,9 @@ test("rejects ports outside the valid range", () => {
expect(Option.isNone(decode({ port: -1 }))).toBe(true) expect(Option.isNone(decode({ port: -1 }))).toBe(true)
expect(Option.isNone(decode({ port: 65_536 }))).toBe(true) expect(Option.isNone(decode({ port: 65_536 }))).toBe(true)
}) })
test("accepts optional app metadata", () => {
expect(
Option.getOrThrow(decode({ app: { name: "sdk", version: "1.2.3", channel: "beta" } })).app,
).toEqual({ name: "sdk", version: "1.2.3", channel: "beta" })
})
+2
View File
@@ -10,6 +10,7 @@ it.live("allows browser preflight requests without credentials", () =>
hostname: "127.0.0.1", hostname: "127.0.0.1",
port: 0, port: 0,
password: "secret", password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" }, database: { path: ":memory:" },
}) })
const response = yield* Effect.promise(() => const response = yield* Effect.promise(() =>
@@ -38,5 +39,6 @@ it.live("allows browser preflight requests without credentials", () =>
expect(health.status).toBe(200) expect(health.status).toBe(200)
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
}), }),
) )
@@ -26,6 +26,19 @@ describe("prompt input v2 interaction machine", () => {
expect(closed.state.popover).toEqual({ type: "closed" }) expect(closed.state.popover).toEqual({ type: "closed" })
}) })
test("completes nested slash command names", () => {
const open = transitionPromptInputV2(
createPromptInputV2InteractionState(),
{ type: "input.changed", value: "/review/" },
persisted(),
)
const item = { ...command, label: "/review/nested" }
const selected = transitionPromptInputV2(open.state, { type: "popover.select", item }, persisted("/review/"))
expect(open.state.popover).toEqual({ type: "command-inline", query: "review/" })
expect(selected.commands).toContainEqual({ type: "draft.setText", value: "/review/nested " })
})
test("opens context completion at the cursor", () => { test("opens context completion at the cursor", () => {
const value = "alpha @sr omega" const value = "alpha @sr omega"
const input = persisted(value) const input = persisted(value)
@@ -102,7 +102,7 @@ function inputChanged(
]) ])
} }
const command = value.match(/^\/([^\s/]*)$/) const command = value.match(/^\/(\S*)$/)
if (command) { if (command) {
const query = command[1] ?? "" const query = command[1] ?? ""
return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [ return changed({ ...state, popover: { type: "command-inline", query }, focus: "editor" }, [
@@ -240,7 +240,7 @@ function populated(persisted: PromptInputV2PersistedState) {
} }
function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) { function replaceTrigger(value: string, trigger: "@" | "/", replacement: string) {
const index = value.lastIndexOf(trigger) const index = trigger === "/" ? value.indexOf(trigger) : value.lastIndexOf(trigger)
return index < 0 ? replacement : value.slice(0, index) + replacement return index < 0 ? replacement : value.slice(0, index) + replacement
} }

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