Compare commits

..

18 Commits

Author SHA1 Message Date
Aiden Cline edf0ce766d fix(core): restore external directory defaults 2026-07-17 18:30:07 +00:00
Aiden Cline 4bc8faa01c feat(core): add tool namespaces (#37529) 2026-07-17 13:14:06 -05:00
Simon Klee 5d5b33f195 cli: rename node package to opencode-node (#37528) 2026-07-17 19:04:40 +02:00
Kit Langton 3fc06accbd fix(simulation): normalize named key presses (#37523) 2026-07-17 12:24:54 -04:00
James Long 677526d72e refactor(tui): migrate dialog views to V2 themes (#37516) 2026-07-17 12:17:11 -04:00
Kit Langton bce9c639d1 feat(tui): expose session composer top slot (#37518) 2026-07-17 11:55:18 -04:00
Kit Langton 48adb9521c feat(tui): expose session header slot (#37520) 2026-07-17 11:55:06 -04:00
opencode-agent[bot] 9b3a4655f1 fix(core): clarify user-executed shell history (#37515)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-17 10:49:02 -05:00
James Long bd79e24842 fix(tui): raise neutral permission backgrounds (#37513) 2026-07-17 11:35:02 -04:00
James Long d67e035c18 refactor(tui): migrate leaf views to V2 themes (#37510) 2026-07-17 11:19:03 -04:00
Simon Klee f8470404e3 fix(cli): stabilize Windows ARM64 Node builds (#37503) 2026-07-17 16:45:14 +02:00
opencode-agent[bot] 651751405d test(tui): await diff tree context readiness (#37501)
Co-authored-by: James Long <longster@gmail.com>
2026-07-17 10:41:00 -04:00
Kit Langton 44f7bb71c1 chore: upgrade Effect to beta.98 (#37498) 2026-07-17 10:31:27 -04:00
James Long b26d948b76 feat(tui): add hovered theme state (#37404) 2026-07-17 10:19:42 -04:00
Simon Klee 2180fb8e7b ci: fix node publish multi-arch install (#37497) 2026-07-17 16:16:43 +02:00
Kit Langton 3db4458e6c feat(session): expose transient generation API (#37433) 2026-07-17 09:25:50 -04:00
Simon Klee 42e2dde739 fix(cli): prevent Node stdout EPIPE failures
Keep draining standalone server stdout after reading its readiness message so
later server output does not write into a closed pipe.

Install the Node CLI broken-pipe handler before loading the main entrypoint.
Ignore only EPIPE while preserving all unrelated stdout errors. This prevents
Windows Node builds from failing when the TUI prints its exit epilogue after
renderer teardown.
2026-07-17 14:46:46 +02:00
Simon Klee a1b274e6f8 node v2 cli support (#36309) 2026-07-17 14:45:06 +02:00
184 changed files with 2734 additions and 1410 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot at the top of the session view.
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/cli": patch
---
Expose a TUI plugin slot above the session composer.
+56
View File
@@ -121,6 +121,55 @@ jobs:
outputs:
version: ${{ needs.version.outputs.version }}
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
settings:
- target: linux-arm64
host: blacksmith-4vcpu-ubuntu-2404-arm
- target: linux-x64
host: blacksmith-4vcpu-ubuntu-2404
- target: darwin-arm64
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
with:
install-flags: --os=* --cpu=*
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- name: Verify service lifecycle
if: matrix.settings.target != 'windows-arm64'
working-directory: packages/cli
run: bun run script/service-smoke.ts --node
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-cli-${{ matrix.settings.target }}
path: packages/cli/dist/node/cli-node-*
if-no-files-found: error
sign-cli-windows:
needs:
- build-cli
@@ -413,6 +462,7 @@ jobs:
needs:
- version
- build-cli
- build-node-cli
- sign-cli-windows
- build-electron
if: always() && !failure() && !cancelled()
@@ -461,6 +511,12 @@ jobs:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
merge-multiple: true
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
with:
+14
View File
@@ -78,6 +78,20 @@ jobs:
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
timeout-minutes: 15
working-directory: packages/cli
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
+2
View File
@@ -11,6 +11,7 @@ node_modules
playground
tmp
dist
dist-node
ts-dist
.turbo
.typecheck-profiles
@@ -25,6 +26,7 @@ Session.vim
a.out
target
.scripts
.cache
.direnv/
# Local dev files
+77 -35
View File
@@ -145,14 +145,28 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
"ws": "8.21.0",
},
"devDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/client": {
@@ -171,7 +185,7 @@
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
"effect": "4.0.0-beta.98",
},
"optionalPeers": [
"effect",
@@ -387,6 +401,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
@@ -554,7 +569,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.98",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -739,9 +754,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.3",
"@opentui/core": ">=0.4.5",
"@opentui/keymap": ">=0.4.5",
"@opentui/solid": ">=0.4.5",
},
"optionalPeers": [
"@opentui/core",
@@ -1021,10 +1036,12 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"string-width": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
},
@@ -1163,12 +1180,12 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1180,9 +1197,9 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1190,9 +1207,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1218,8 +1235,9 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.83",
"effect": "4.0.0-beta.98",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
@@ -1228,11 +1246,13 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.0",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
@@ -1619,13 +1639,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.98", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.98" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITfK8xhcl+9GXOvPwzADWkOQ+dgUGZrJNefT3r2+uLFmzjyKRLtHzhLOl6lZaLSsf5io13+nmt8adfMRQPq+oA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2273,27 +2293,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -4017,7 +4037,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -4193,7 +4213,7 @@
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -5057,7 +5077,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
@@ -5065,7 +5085,7 @@
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
@@ -5595,6 +5615,8 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
@@ -5963,7 +5985,7 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
@@ -6049,7 +6071,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"undici": ["undici@8.7.0", "", {}, "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -6131,7 +6153,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
"valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="],
@@ -8341,6 +8363,26 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"@standard-community/standard-json/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"@standard-community/standard-json/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"@standard-community/standard-json/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"@standard-community/standard-json/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"@standard-community/standard-openapi/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
"@standard-community/standard-openapi/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"@standard-community/standard-openapi/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
"@standard-community/standard-openapi/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
"@standard-community/standard-openapi/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
-428
View File
@@ -1,428 +0,0 @@
# V2 Desktop and Web API Migration
## Decision
Desktop and web should move from the legacy `@opencode-ai/sdk` transport to
`@opencode-ai/client` and the standalone V2 server. Metadata, lifecycle,
location, integration, and project-copy state should use V2 models directly.
The existing app timeline should remain on its V1 `Message` and `Part` shape
during this migration. A narrow app-side compatibility projection should map
V2 session messages and events into that shape. This is not runtime V1 support:
the app will call only V2 endpoints, and the standalone server will expose only
V2 APIs. The compatibility boundary exists solely to avoid rewriting the mature
timeline and message-part UI at the same time as the transport migration.
The migration is larger than changing HTTP paths. The important contract
changes are:
- A prompt is durable asynchronous admission, not a request that returns a
completed assistant message.
- Session messages are V2 projections on the wire. The app adapter projects
them into a legacy message plus parts without treating that shape as durable
server state.
- The global event stream carries `{ type, data, location }`, not
`{ directory, payload: { type, properties } }`.
- Location is explicit on requests and session placement is durable.
- Most mutations return `204`; read responses use `{ data }`, pagination uses
`{ data, cursor }`, and location reads use `{ location, data }`.
- Provider authentication is integration and credential management.
- Revert is a staged operation.
This document compares the 125 legacy endpoints in `packages/opencode` with the
105 endpoints assembled by `packages/protocol/src/api.ts`. The standalone V2
server mounts the latter through `packages/server/src/routes.ts`.
## Current Architecture
The browser and desktop renderer both use `packages/app`. The app currently:
- creates `@opencode-ai/sdk/v2/client` clients in
`packages/app/src/utils/server.ts`;
- creates implicit directory-scoped clients in
`packages/app/src/context/server-sdk.tsx`;
- consumes the legacy `/global/event` envelope;
- stores legacy `Session`, `Message`, and `Part` values in
`packages/app/src/context/server-session.ts` and global sync;
- submits with `/session/:id/prompt_async` and observes legacy message-part
events.
The `v2` import path in `@opencode-ai/sdk` is the generated client for the V1
server API. It is not the client for the V2 architecture. The V2 Promise client
is exported by `@opencode-ai/client` from `packages/client/src/promise`.
Desktop embeds the same app but starts the V1 backend:
- `packages/desktop/electron.vite.config.ts` bundles
`packages/opencode/dist/node/node.js`;
- `packages/desktop/src/main/sidecar.ts` calls legacy `Server.listen`;
- `packages/desktop/src/main/server.ts` probes `/global/health`.
The V2 process entry is `packages/server/src/process.ts`. It requires a
password, exposes `/api/health`, and returns an address plus a shutdown effect.
## Complete Endpoint Comparison
The tables group every public endpoint by domain. `Direct` means the app can
adopt the V2 operation after request and response shape changes. `Redesign`
means the user workflow exists but its semantics or state model changed.
`Gap` means V2 has no corresponding operation and a product or protocol
decision is required.
### Server, Configuration, and Discovery
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /global/health` | `GET /api/health` | Direct. V2 adds `pid`. |
| `POST /global/dispose`, `POST /instance/dispose` | `POST /api/service/stop` | Redesign. V2 stops a managed instance by `instanceID`; it does not expose location disposal as an app command. |
| `POST /global/upgrade` | None | Gap. Desktop updater should own application upgrades; remote server upgrade needs a separate decision. |
| `GET/PATCH /global/config`, `GET/PATCH /config` | None | Gap. Configuration editing is used throughout settings and provider UI. Add a V2 config group or remove those controls. |
| `GET /path` | `GET /api/location` | Redesign. V2 returns placement identity, not host config/state/home paths. |
| None | `GET /api/server` | New. Returns advertised server URLs. |
| None | `GET/DELETE /api/debug/location` | New diagnostics; not an app bootstrap dependency. |
| `POST /log` | None | Gap. Client telemetry should use its own transport rather than a compatibility route. |
### Catalog, Providers, and Integrations
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /agent` | `GET /api/agent` | Direct. |
| `GET /command` | `GET /api/command` | Direct. |
| `GET /skill` | `GET /api/skill` | Direct. |
| None | `GET /api/plugin`, `GET /api/reference` | New catalogs. |
| `GET /provider`, `GET /config/providers` | `GET /api/provider`, `GET /api/provider/:providerID`, `GET /api/model`, `GET /api/model/default` | Redesign around separate provider and model catalogs. |
| `GET /provider/auth`, `POST /provider/:id/oauth/authorize`, `POST /provider/:id/oauth/callback`, `PUT/DELETE /auth/:id` | Integration and credential routes below | Redesign. Do not carry legacy auth calls forward. |
| None | `GET /api/integration`, `GET /api/integration/:id` | New integration catalog and connection state. |
| None | `POST /api/experimental/integration/wellknown` | New discovery operation. |
| None | `POST /api/integration/:id/connect/key` | Replaces direct API-key storage. |
| None | `POST /api/integration/:id/connect/oauth`, `GET/DELETE /api/integration/:id/connect/oauth/:attemptID`, `POST /api/integration/:id/connect/oauth/:attemptID/complete` | Replaces legacy OAuth with explicit attempts. |
| None | `POST /api/integration/:id/connect/command`, `GET/DELETE /api/integration/:id/connect/command/:attemptID` | New command-based connection flow. |
| None | `PATCH/DELETE /api/credential/:credentialID` | New credential lifecycle. |
| None | `POST /api/generate` | New stateless generation endpoint. |
### Projects, Copies, Workspaces, and Sync
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /project`, `GET /project/current`, `GET /project/:id/directories` | Same operations under `/api` | Direct, with V2 response shapes and explicit location. |
| `PATCH /project/:id` | None | Gap. The app edits project name and icon. Add project metadata mutation if this remains a server-owned feature. |
| `POST /project/git/init` | None | Gap. Add a VCS initialization operation or remove the action. |
| `POST /experimental/project/:id/copy/generate-name` | None | Gap. Name generation can be client-side or a separate generation request. |
| `GET/POST/DELETE /experimental/worktree`, `POST /experimental/worktree/reset` | Project-copy routes below | Redesign around project copies and strategies. |
| None | `POST/DELETE /experimental/project/:id/copy`, `POST /experimental/project/:id/copy/refresh` | New project-copy model. These are currently missing the `/api` prefix and must be fixed before cutover. |
| All seven `/experimental/workspace*` endpoints | None | Gap by design unless remote workspace placement remains a product requirement. |
| All four `/sync/*` endpoints | Native V2 durable storage and location | Remove. These are V1 synchronization internals, not client API equivalents. |
| `POST /experimental/control-plane/move-session` | `POST /api/session/:id/move` | Direct at the session API level. |
### Sessions and Messages
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /session` | `GET /api/session` | Direct with cursor pagination and different filters. |
| `GET /session/status` | `GET /api/session/active` | Redesign. V2 reports process-local active drains, not a status for every session. Idle state comes from projection and events. |
| `GET /session/:id` | `GET /api/session/:id` | Direct. |
| `GET /session/:id/children` | `GET /api/session?parentID=...` | Direct through list filtering. |
| `POST /session` | `POST /api/session` | Direct; pass durable location, agent, and model. |
| `DELETE /session/:id` | `DELETE /api/session/:id` | Direct; V2 also removes children. |
| `PATCH /session/:id` | `POST /api/session/:id/rename`, `/agent`, `/model`, `/move` | Redesign. Metadata, permission, and archive mutation have no equivalent. |
| `POST /session/:id/fork` | `POST /api/session/:id/fork` | Direct. |
| `POST /session/:id/abort` | `POST /api/session/:id/interrupt` | Redesign. Interrupt is an idle-safe, process-local operation returning `204`. |
| `POST /session/:id/init` | None | Gap. Remove AGENTS generation or add it as a named V2 command/workflow. |
| `POST/DELETE /session/:id/share` | None | Gap. Sharing needs a V2 design rather than retaining V1 session mutation. |
| `POST /session/:id/summarize` | `POST /api/session/:id/compact` | Redesign around durable compaction. |
| `POST /session/:id/message`, `POST /session/:id/prompt_async` | `POST /api/session/:id/prompt` | Redesign. V2 returns admitted pending input; completion is observed asynchronously. |
| `POST /session/:id/command` | `POST /api/session/:id/command` | Redesign request fields and delivery semantics. |
| `POST /session/:id/shell` | `POST /api/session/:id/shell` | Redesign. V2 emits shell lifecycle events and returns `204`. |
| None | `POST /api/session/:id/skill`, `/synthetic`, `/background`, `/wait` | New session operations. |
| `POST /session/:id/revert`, `/unrevert` | `POST /api/session/:id/revert/stage`, `/clear`, `/commit` | Redesign the undo UI around staged revert. |
| `GET /session/:id/message` | `GET /api/session/:id/message` | Redesign around `SessionMessage.Info[]` and body cursors. |
| `GET /session/:id/message/:messageID` | Same operation under `/api` | Direct after adopting V2 message types. |
| `DELETE /session/:id/message/:messageID`, `DELETE/PATCH .../part/:partID` | None | Remove. V2 projections are not directly mutable transcript storage. |
| `GET /session/:id/diff` | `GET /api/vcs/diff` only | Gap. Decide whether the review UI needs a session-scoped derived diff endpoint. |
| None | `GET /api/session/:id/context`, `/pending` | New projection and inbox reads. |
| None | `GET/PUT/DELETE /api/session/:id/instructions/entries[/key]` | New durable instruction entries. |
| None | `GET /api/experimental/session/:id/log` | New durable event log for diagnostics/recovery views. |
### Permission, Question, and Form
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /permission` | `GET /api/permission/request` | Direct for cross-location bootstrap. |
| Deprecated `POST /session/:id/permissions/:permissionID` and `POST /permission/:requestID/reply` | `POST /api/session/:id/permission/:requestID/reply` | Redesign around current replies and required session identity. |
| None | `POST/GET /api/session/:id/permission`, `GET .../:requestID` | New session-scoped request API. |
| None | `GET /api/permission/saved`, `DELETE /api/permission/saved/:id` | New saved decisions. |
| `GET /question`, `POST /question/:id/reply`, `POST /question/:id/reject` | `GET /api/question/request`, `GET /api/session/:id/question`, session-scoped reply/reject | Direct after adding session identity and new types. |
| None | `GET /api/form/request`, all six `/api/session/:id/form*` operations | New structured interaction surface. |
### Filesystem and VCS
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /file` | `GET /api/fs/list` | Direct with new entry types. |
| `GET /file/content` | `GET /api/fs/read/*` | Redesign. V2 returns bytes; the app must decode text and model binary/media content. |
| `GET /find/file` | `GET /api/fs/find` | Direct with a unified entry result. |
| `GET /find`, `GET /find/symbol` | None | Gap. Add text/symbol search only if required by current UI. |
| `GET /file/status` | `GET /api/vcs/status` | Direct. |
| `GET /vcs/status`, `GET /vcs/diff` | Same operations under `/api` | Direct. |
| `GET /vcs`, `GET /vcs/diff/raw`, `POST /vcs/apply` | None | Gap. Branch display, raw patch, and apply actions need explicit V2 endpoints or removal. |
| `GET /lsp`, `GET /formatter` | None | Gap. Status UI must be removed or supported by new read models. |
### PTY and Shell
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET/POST /pty`, `GET/PUT/DELETE /pty/:id` | Same operations under `/api` | Direct with explicit location and location envelopes. |
| `POST /pty/:id/connect-token`, `GET /pty/:id/connect` | Same operations under `/api` | Direct. Require ticket flow and remove legacy query-auth fallback. |
| `GET /pty/shells` | None | Gap. Use a client default shell or add server shell discovery. |
| None | `GET/POST /api/shell`, `GET /api/shell/:id`, `PATCH .../timeout`, `GET .../output`, `DELETE .../:id` | New background noninteractive shell API. |
### MCP, Events, and Experimental Controls
| V1 endpoints | V2 endpoints | Result |
| --- | --- | --- |
| `GET /mcp` | `GET /api/mcp` | Redesign around server catalog values rather than a status map. |
| `GET /experimental/resource` | `GET /api/mcp/resource` | Direct with a location envelope. |
| The seven V1 MCP add/connect/disconnect/auth endpoints | None | Redesign through integrations. Add MCP-specific mutations only for behavior integrations cannot represent. |
| `GET /global/event`, `GET /event` | `GET /api/event` | Redesign event routing and reducers. V2 intentionally uses one global cross-location stream. |
| All twelve `/tui/*` endpoints | None | Remove from desktop/web scope. They are TUI remote-control APIs. |
| `/experimental/capabilities`, `/console*`, `/tool*`, global experimental session/background endpoints | Session background and catalog APIs where applicable | Remove or redesign per feature. These are not general app bootstrap contracts. |
## Required Protocol Work Before Cutover
The app cannot reach feature parity against the current V2 surface. Resolve
these items before switching production desktop/web:
1. Add V2 configuration read/update operations, or deliberately remove server
configuration editing from the app.
2. Decide whether project metadata mutation and git initialization remain app
features; add narrow V2 operations if they do.
3. Decide archive semantics. Do not encode archive as a hidden compatibility
field on session rename.
4. Decide whether sharing is part of V2. Remove the UI until a V2 sharing model
exists.
5. Decide whether session-derived diffs are required. VCS working-tree diff is
not equivalent to a session range diff.
6. Decide whether branch, LSP, formatter, text-search, symbol-search, and shell
discovery status remain visible in the app.
7. Move project-copy routes from `/experimental/project/...` to
`/api/experimental/project/...`. They are currently the only V2 group outside
the V2 namespace and are normalized as legacy routes by the combined server.
Missing features should be removed or implemented as native V2 contracts. They
should not be routed through `packages/opencode`, which is V1 reference code.
## Application Design
### Client and Location
Replace `@opencode-ai/sdk` for runtime calls with `@opencode-ai/client` in
`packages/app`. `createSdkForServer` should construct the generated Promise
client with the existing Basic authorization and platform fetch behavior. Keep
the V1 session types as a type-only compatibility dependency while the existing
timeline consumes them.
Keep one server client. Replace implicit directory clients with a small
application context containing:
```ts
{
client: OpenCodeClient
location: Location.Ref
}
```
Every location-scoped call should pass that location. Session-scoped calls use
the session's durable placement and should not infer placement from the active
route after creation.
### Event Stream
Subscribe with `client.event.subscribe()`. Route an event to location state by
`event.location?.directory`, with a separate global channel when location is
absent. Preserve the current bounded UI batching and reconnect behavior, but
remove conversion to legacy `payload.properties`.
Metadata reducers should consume V2 definitions directly. Transcript events
should go through the timeline adapter, which updates its V1-shaped projection
from session step, text, reasoning, tool, input, retry, compaction, and
execution events. Do not manufacture legacy `message.*` events and feed them
back through the old reducer; apply the V2 event once at the compatibility
boundary and replace only the affected projected message and parts.
The live stream is lossy across disconnects. On reconnect, reload active
session projections and request lists rather than assuming event replay. The
durable experimental session log is useful for diagnostics but should not be a
hidden replacement for projection reads.
### Session Store and Timeline
Keep the legacy normalized `Message` and `Part` stores and existing timeline
components. Add the compatibility boundary at
`packages/app/src/context/v2/session-timeline-adapter.ts`. Paging should call the
V2 message endpoint with body cursors, then replace the adapter from the returned
`SessionMessage.Info[]` projection.
The adapter should expose a complete projection for one session:
```ts
type LegacyTimelineProjection = {
messages: Message[]
parts: Record<string, Part[]>
status: SessionStatus
notices: SessionMessageInfo[]
}
type TimelineAdapter = {
replace(messages: readonly SessionMessageInfo[]): LegacyTimelineProjection
apply(event: OpenCodeEvent): LegacyTimelineProjection
sourceMessageID(legacyID: string): string | undefined
legacyMessageID(sourceID: string): string | undefined
reset(): void
}
```
Use `packages/core/src/session/message-updater.ts` as the authoritative event
fold and `packages/tui/src/context/data.tsx` as the browser-side live-update
reference. There is currently no reusable V2-to-V1 converter.
Projection rules:
- Preserve V2 user and assistant IDs where doing so does not violate app
ordering assumptions. Map user text, files, and agents to the corresponding
V1 parts.
- Map assistant text and reasoning content to V1 text and reasoning parts. V2
content has no part ID, so derive a stable part ID from source message ID and
absolute content index.
- Map V2 tool `streaming`, `running`, `completed`, and `error` states to V1
`pending`, `running`, `completed`, and `error`. Preserve the V2 tool ID as the
V1 `callID`; derive the V1 part ID independently.
- Serialize textual tool content into V1 output and map file content to
attachments. Preserve structured values and V2-only state under compatibility
metadata only when an existing component needs it.
- Map V2 retry state to a V1 retry part and retry session status. Map assistant
errors to the closest V1 error type while retaining the source error type.
- Map V2 compaction to a synthetic V1 user message with a compaction part so the
existing divider remains intact.
- Keep agent/model switches as adapter selection state. Do not fabricate false
user turns for them.
- Keep synthetic, system, skill, and shell messages in `notices` until a narrow
display mapping is defined. Fabricating user messages for them would corrupt
assistant parent and turn semantics.
The conversion is intentionally lossy for historical path, parent identity,
structured tool result values, and some provider error details. These fields do
not have V2 equivalents. Defaults should be isolated in the adapter and tested,
not spread through timeline components.
V2 projection order is authoritative and is not guaranteed to match message ID
order. Queued input may be promoted after a later steer, and compaction may
reuse an admitted ID. `server-session.ts` currently uses sorted-ID binary
searches, so it must preserve array order independently of IDs. If a generated
legacy ID is needed, retain a bidirectional source-ID map for fork, revert, deep
links, and optimistic reconciliation.
With this boundary, most files under
`packages/app/src/pages/session/timeline/` and the legacy message-part renderers
can remain unchanged. Necessary changes are concentrated in:
- `packages/app/src/context/server-session.ts`;
- `packages/app/src/context/global-sync/event-reducer.ts`;
- `packages/app/src/context/global-sync/types.ts` for V2 session metadata while
retaining V1 transcript types;
- `packages/app/src/context/server-sdk.tsx`;
- `packages/app/src/context/sync.tsx`.
### Prompt and Execution
Build V2 prompt input as text plus file and agent attachments. Map the existing
steer/queue UI directly to `delivery`. Use `session.prompt` for admission and
observe execution through projection/events. Use `session.interrupt`, staged
revert operations, and `session.compact` for their corresponding controls.
The app must not wait for prompt HTTP completion to obtain the assistant
message. An optional `session.wait` is suitable for explicit blocking workflows,
not the interactive composer.
### Integrations and Requests
Rebuild provider settings around integration connection methods and credential
records. Migrate permission and question bootstrap to cross-location request
lists, while replies use session-scoped endpoints. Add form handling as a new
request-dock type rather than forcing forms into question types.
### Files and Terminals
Decode `fs.read` bytes according to the UI use case and preserve binary/media
detection in one application boundary. Move terminal HTTP and WebSocket paths
to `/api/pty`, pass nested location query values, and require connect tickets.
## Desktop Design
Desktop should bundle a V2 entry that runs `ServerProcess.start`, not the V1
`Server.listen`. The sidecar needs a narrow Promise-facing wrapper that:
1. builds and runs the Effect server layer in the worker;
2. reports ready only after `ServerProcess.start` returns its bound address;
3. retains the running fiber/scope for shutdown;
4. passes the existing password, loopback hostname, selected port, and desktop
origin CORS policy;
5. preserves the existing sidecar worker isolation and crash reporting.
Then update desktop health checks to `/api/health`. The renderer remains a
normal authenticated HTTP client and does not need a new preload API.
Likely files:
- `packages/desktop/electron.vite.config.ts`;
- `packages/desktop/src/main/env.d.ts`;
- `packages/desktop/src/main/sidecar.ts`;
- `packages/desktop/src/main/server.ts`;
- `packages/desktop/package.json`.
The standalone server currently receives CORS through its route construction,
so the wrapper must verify `oc://renderer` is accepted before replacing the V1
sidecar.
## Migration Sequence
1. Resolve the protocol gaps required for the retained product scope, fix the
project-copy namespace, and regenerate `packages/client`.
2. Add V2 client construction, explicit location context, `/api/health`, and the
native event subscription behind a development-only entry point.
3. Add the V2-to-V1 timeline adapter, switch transcript paging and live events
to V2, and retain the existing timeline renderers. Record the required
production timeline benchmark baseline before this step and compare it
afterward.
4. Migrate prompt, command, shell, interrupt, compact, fork, revert, permission,
question, and form workflows.
5. Migrate filesystem, VCS, PTY, projects, project copies, catalogs, integrations,
and credentials.
6. Switch desktop packaging and lifecycle to the V2 server process.
7. Remove runtime `@opencode-ai/sdk` calls, V1 event handling, directory-scoped
SDK clients, fallback endpoint probes, and unsupported V1-only UI. Retain
only the type-level V1 transcript compatibility surface used by the adapter.
8. Remove transitional V1 event definitions from the V2 public event manifest
after all current consumers stop depending on them, then regenerate clients.
This order isolates the deliberate V1 timeline shape behind one V2 projection
boundary. Transcript and event conversion is the highest-risk work and should
land before the broad set of mechanical endpoint migrations.
## Verification
Each migration slice should verify:
- generated Promise client request and response types;
- app typecheck and focused state/timeline tests;
- event reconnect followed by projection reconciliation;
- simultaneous sessions in different locations;
- prompt steer, queue, interrupt, retry, tool, permission, question, and form
flows;
- PTY ticket connection and reconnect;
- desktop worker startup, authenticated `/api/health`, graceful shutdown, and
crash recovery;
- browser and desktop behavior on both mobile-sized and desktop viewports.
Run package checks from their package directories. Public Protocol or Server
`HttpApi` changes require `bun run generate` from `packages/client`; generated
files must not be edited directly.
+11 -8
View File
@@ -37,18 +37,18 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
@@ -69,12 +69,13 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.83",
"effect": "4.0.0-beta.98",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked-shiki": "1.2.1",
@@ -85,9 +86,11 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"string-width": "7.2.0",
"tailwindcss": "4.1.11",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
@@ -163,7 +166,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
+7 -4
View File
@@ -31,11 +31,13 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".opencode2")
const command = path.basename(__filename).replace(/\.cjs$/, "")
const nodeBuild = command === "opencode2-node"
const cached = path.join(scriptDir, `.${command}`)
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
const base = `@opencode-ai/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch
const binary = platform === "windows" ? `${command}.exe` : command
function supportsAvx2() {
if (arch !== "x64") return false
@@ -77,6 +79,7 @@ function supportsAvx2() {
}
const names = (() => {
if (nodeBuild) return [base]
const baseline = arch === "x64" && !supportsAvx2()
if (platform === "linux") {
const musl = (() => {
@@ -121,7 +124,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) {
console.error(
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
`It seems that your package manager failed to install the right ${command} CLI package. Try manually installing ` +
names.map((name) => `"${name}"`).join(" or ") +
" package",
)
+17 -2
View File
@@ -26,6 +26,7 @@
},
"scripts": {
"build": "bun run script/build.ts",
"build:node": "bun run script/build-node.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
@@ -51,7 +52,8 @@
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3"
"uqr": "0.1.3",
"ws": "8.21.0"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@@ -59,6 +61,19 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
}
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
if (outdir === path.join(dir, "dist-node")) {
throw new Error("--outdir must not be dist-node because it contains temporary files")
}
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
nodeTarget("linux", "x64"),
nodeTarget("darwin", "arm64"),
nodeTarget("win32", "arm64"),
nodeTarget("win32", "x64"),
]
const targets = requested
? allTargets.filter((target) => targetName(target) === requested)
: single || bundleOnly
? [nodeTarget(process.platform, process.arch)]
: allTargets
if (targets.length === 0) {
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
throw new Error(`Unknown Node target: ${requested}`)
}
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
throw new Error("Node 26.4 SEA does not support macOS x64")
}
process.chdir(dir)
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
if (!builder) throw new Error("Node SEA builder is unavailable")
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
}
if (bundleOnly) continue
const name = `cli-node-${targetName(target)}`
const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node"
const output = path.join(outdir, name, "bin", binary)
if (!builder) throw new Error("Node SEA builder is unavailable")
await mkdir(path.dirname(output), { recursive: true })
const config = {
main: "dist-node/opencode.mjs",
mainFormat: "module",
executable: await resolveTargetNode(target, builder),
output: path.relative(dir, output),
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
execArgv: nodeExecArgv,
execArgvExtension: "none",
assets: await seaAssetMap(),
}
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
run(builder, ["--build-sea", "dist-node/sea.json"])
if (target.platform !== "win32") await chmod(output, 0o755)
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
if (target.platform === "darwin" && process.platform !== "darwin") {
console.warn(`${output} must be signed on macOS before it can run`)
}
await writeFile(
path.join(outdir, name, "package.json"),
`${JSON.stringify(
{
name: `@opencode-ai/${name}`,
version: Script.version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: [target.platform],
cpu: [target.arch],
},
null,
2,
)}\n`,
)
if (host) await smoke(output)
}
async function resolveHostNode() {
const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
for (const candidate of candidates) {
const result = spawnSync(
candidate,
["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
{
encoding: "utf8",
},
)
if (result.status !== 0) continue
const info = JSON.parse(result.stdout) as { version: string; path: string }
if (info.version === NODE_VERSION) return realpath(info.path)
}
return resolveTargetNode(nodeTarget(process.platform, process.arch))
}
async function resolveTargetNode(target: NodeTarget, host?: string) {
if (host && target.platform === process.platform && target.arch === process.arch) return host
const cache = path.resolve(dir, ".cache", "node")
const platform = target.platform === "win32" ? "win" : target.platform
const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
const targetDirectory = path.join(cache, archiveName)
const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
if (
(await stat(executable).then(
() => true,
() => false,
)) &&
(await stat(path.join(targetDirectory, ".verified")).then(
() => true,
() => false,
))
)
return realpath(executable)
await mkdir(cache, { recursive: true })
const extension = target.platform === "win32" ? "zip" : "tar.gz"
const filename = `${archiveName}.${extension}`
const archive = path.join(cache, filename)
const base = `https://nodejs.org/dist/v${NODE_VERSION}`
const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
const data = new Uint8Array(await response.arrayBuffer())
const expected = (await sums.text())
.split("\n")
.find((line) => line.endsWith(` ${filename}`))
?.split(/\s+/)[0]
if (!expected) throw new Error(`Missing checksum for ${filename}`)
if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
await writeFile(archive, data)
const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
await rm(temporary, { recursive: true, force: true })
await mkdir(temporary)
if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
if (target.platform === "win32" && process.platform === "win32") {
run(path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe"), ["-xf", archive, "-C", temporary])
}
if (target.platform === "win32" && process.platform !== "win32") run("unzip", ["-q", archive, "-d", temporary])
await rm(targetDirectory, { recursive: true, force: true })
await rename(path.join(temporary, archiveName), targetDirectory)
await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
await rm(temporary, { recursive: true, force: true })
await rm(archive, { force: true })
return realpath(executable)
}
async function smoke(output: string) {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
const executable = path.join(root, path.basename(output))
await copyFile(output, executable)
if (process.platform !== "win32") await chmod(executable, 0o755)
run(executable, ["--version"], root)
run(executable, ["--help"], root)
await rm(root, { recursive: true, force: true })
}
function targetName(target: NodeTarget) {
return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
}
function run(command: string, args: readonly string[], cwd = dir) {
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+9 -13
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
@@ -11,9 +10,14 @@ import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
@@ -50,10 +54,6 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@@ -67,7 +67,7 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts", parserWorker],
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
@@ -81,7 +81,7 @@ for (const item of targets) {
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: `./dist/${name}/bin/${binary}`,
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
@@ -93,10 +93,6 @@ for (const item of targets) {
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@@ -107,7 +103,7 @@ for (const item of targets) {
}
await Bun.write(
`./dist/${name}/package.json`,
path.join(outdir, name, "package.json"),
JSON.stringify(
{
name: `@opencode-ai/${name}`,
+3 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")
+83
View File
@@ -0,0 +1,83 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
// Bun's compiler discovers file imports and embeds them in its virtual filesystem. Vite only bundles the JavaScript
// portion of the Node executable, while SEA embeds only the assets explicitly listed in its build configuration.
// Collect and stage those files under stable keys so the SEA prelude can extract them to real paths at startup;
// native addons, helper executables, and other path-based consumers cannot use assets directly from SEA memory.
export type NodeAsset = {
readonly key: string
readonly source: string
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
export async function collectNodeAssets(target: NodeTarget) {
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
...getNodeAssets({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
}),
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
},
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(await files(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
key: `${target.nodePtyPackage}/${relative}`,
source: path.join(ptyRoot, relative),
})),
]
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
const hash = createHash("sha256")
for (const asset of assets.toSorted((left, right) => left.key.localeCompare(right.key))) {
hash.update(asset.key)
hash.update(await readFile(asset.source))
}
return hash.digest("hex").slice(0, 16)
}
export async function copyNodeAssets(assets: readonly NodeAsset[]) {
const root = path.join(dir, "dist-node", "assets")
await Promise.all(
assets.map(async (asset) => {
const target = path.join(root, asset.key)
await mkdir(path.dirname(target), { recursive: true })
await copyFile(asset.source, target)
}),
)
}
export async function seaAssetMap() {
const root = path.join(dir, "dist-node", "assets")
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
}
+50 -32
View File
@@ -18,37 +18,55 @@ async function publish(dir: string, name: string, version: string) {
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const item = await Bun.file(`./dist/${filepath}`).json()
binaries[item.name] = item.version
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
if (!item.name.startsWith(input.packagePrefix)) continue
binaries[item.name] = item.version
}
console.log(input.name, "binaries", binaries)
const versions = new Set(Object.values(binaries))
if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`)
const version = versions.values().next().value
if (!version) throw new Error(`No binary packages found for ${input.name}`)
await $`mkdir -p ${input.root}/${input.name}/bin`
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
await Bun.file(`${input.root}/${input.name}/package.json`).write(
JSON.stringify(
{
name: input.name,
bin: { [input.binary]: `./bin/${input.binary}` },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(
Object.entries(binaries).map(([name, version]) =>
publish(`${input.root}/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
}
console.log("binaries", binaries)
const version = Object.values(binaries)[0]
const name = pkg.name
await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify(
{
name,
bin: { opencode2: "./bin/opencode2" },
version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: ["darwin", "linux", "win32"],
cpu: ["arm64", "x64"],
optionalDependencies: binaries,
},
null,
2,
),
)
await Promise.all(
Object.entries(binaries).map(([name, version]) =>
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
),
)
await publish(`./dist/${name}`, name, version)
await publishDistribution({
root: "./dist",
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
})
await publishDistribution({
root: "./dist/node",
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
})
+4 -3
View File
@@ -7,9 +7,10 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
const nodeBuild = process.argv.includes("--node")
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
+14 -5
View File
@@ -1,5 +1,6 @@
import { EOL } from "node:os"
import path from "node:path"
import { readFile, stat, writeFile } from "node:fs/promises"
import { Effect, Option } from "effect"
import { applyEdits, modify } from "jsonc-parser"
import { Global } from "@opencode-ai/core/global"
@@ -35,7 +36,7 @@ export default Runtime.handler(
}),
)
async function resolveConfigPath(directory: string) {
export async function resolveConfigPath(directory: string) {
const candidates = [
path.join(directory, "opencode.json"),
path.join(directory, "opencode.jsonc"),
@@ -43,16 +44,24 @@ async function resolveConfigPath(directory: string) {
path.join(directory, ".opencode", "opencode.jsonc"),
]
for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) return candidate
if (
await stat(candidate).then(
(info) => info.isFile(),
() => false,
)
)
return candidate
}
return candidates[0]
}
async function write(configPath: string, name: string, server: unknown) {
const file = Bun.file(configPath)
const text = (await file.exists()) ? await file.text() : "{}"
const text = await readFile(configPath, "utf8").catch((error) => {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
throw error
})
const edits = modify(text, ["mcp", "servers", name], server, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
await Bun.write(configPath, applyEdits(text, edits))
await writeFile(configPath, applyEdits(text, edits))
}
+4 -3
View File
@@ -4,6 +4,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { transparent, type RunFooterTheme } from "./theme"
import { Locale } from "@opencode-ai/tui/util/locale"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
export const FOOTER_MENU_ROWS = 8
@@ -196,7 +197,7 @@ export function RunFooterMenu(props: {
...props
.items()
.filter((item) => item.description)
.map((item) => Bun.stringWidth(item.display)),
.map((item) => stringWidth(item.display)),
)
return width === 0 ? 0 : width + 2
})
@@ -205,14 +206,14 @@ export function RunFooterMenu(props: {
return ""
}
return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display)))
return " ".repeat(Math.max(1, descriptionColumn() - stringWidth(item.display)))
}
const descriptionText = (item: RunFooterMenuItem) => {
if (!item.description) {
return
}
const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0
const footerWidth = item.footer ? stringWidth(item.footer) + 1 : 0
const available =
term().width -
(border() ? 1 : 0) -
+9 -12
View File
@@ -5,14 +5,15 @@
// It produces a PromptState that RunPromptBody renders as a slim single-line
// composer while the footer view renders any active menus below it.
/** @jsxImportSource @opentui/solid */
import { pathToFileURL } from "bun"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort"
import path from "path"
import { pathToFileURL } from "node:url"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { Locale } from "@opencode-ai/tui/util/locale"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
import {
createPromptHistory,
displayCharAt,
@@ -602,7 +603,7 @@ export function createPromptState(input: PromptInput): PromptState {
})
}
const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => {
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
draft = clonePrompt(value)
setShell(value.mode === "shell")
if (!area || area.isDestroyed) {
@@ -612,7 +613,7 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
area.setText(value.text)
restoreParts(value.parts)
area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText))
area.cursorOffset = Math.min(cursor, stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -643,7 +644,7 @@ export function createPromptState(input: PromptInput): PromptState {
area.setText(text)
clearParts()
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText))
area.cursorOffset = Math.min(stringWidth(text), stringWidth(area.plainText))
scheduleRows()
area.focus()
}
@@ -777,7 +778,7 @@ export function createPromptState(input: PromptInput): PromptState {
if (move(dir, event)) return
if (!area || area.isDestroyed) return false
const endOffset = Bun.stringWidth(area.plainText)
const endOffset = stringWidth(area.plainText)
if (dir === -1) {
if (area.cursorOffset === 0) return false
if (area.visualCursor.visualRow === 0) {
@@ -886,16 +887,12 @@ export function createPromptState(input: PromptInput): PromptState {
area.cursorOffset = 0
const start = area.logicalCursor
area.cursorOffset =
shell() || !head
? cursor
: local
? Bun.stringWidth(area.plainText)
: Bun.stringWidth(area.plainText.slice(0, head.end))
shell() || !head ? cursor : local ? stringWidth(area.plainText) : stringWidth(area.plainText.slice(0, head.end))
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(text)
area.cursorOffset = Bun.stringWidth(text)
area.cursorOffset = stringWidth(text)
hide()
syncDraft()
if (!shell()) {
@@ -920,7 +917,7 @@ export function createPromptState(input: PromptInput): PromptState {
const text = "@" + next.value
const startOffset = at()
const endOffset = startOffset + Bun.stringWidth(text)
const endOffset = startOffset + stringWidth(text)
const part = structuredClone(next.part)
if (part.type === "agent") {
part.source = {
+4 -2
View File
@@ -4,6 +4,8 @@ import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
import { readStdin } from "../util/io"
import { setTimeout } from "node:timers/promises"
export type MiniCommandInput = {
server: ServerConnection.Resolved
@@ -22,7 +24,7 @@ export type MiniCommandInput = {
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const runtimeTask = import("./runtime")
const directory = localDirectory()
@@ -123,7 +125,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
return
}
if (agent) return name
await Bun.sleep(25)
await setTimeout(25)
}
if (!agents) {
warning("failed to list agents. Falling back to default agent")
+3 -2
View File
@@ -1,6 +1,7 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
import { UI } from "./ui"
import type { MiniToolPart } from "./types"
@@ -478,11 +479,11 @@ async function prepareFile(file: File) {
if (file.mime !== "text/plain") {
const uri = file.url.startsWith("data:")
? file.url
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
return { attachment: { uri, mime: file.mime, name: file.filename } }
}
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
: await readFile(new URL(file.url), "utf8")
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
+4 -3
View File
@@ -8,6 +8,7 @@
// the current draft is saved and history begins. Arrowing past the end
// restores the draft.
export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
import { stringWidth } from "@opencode-ai/tui/util/string-width"
import type { RunPrompt } from "./types"
const HISTORY_LIMIT = 200
@@ -102,7 +103,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
return { state, apply: false }
}
if (dir === 1 && cursor !== Bun.stringWidth(text)) {
if (dir === 1 && cursor !== stringWidth(text)) {
return { state, apply: false }
}
@@ -136,7 +137,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: null,
},
text: state.draft,
cursor: Bun.stringWidth(state.draft),
cursor: stringWidth(state.draft),
apply: true,
}
}
@@ -147,7 +148,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
index: idx,
},
text: state.items[idx].text,
cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text),
cursor: dir === -1 ? 0 : stringWidth(state.items[idx].text),
apply: true,
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
@@ -48,7 +49,7 @@ async function run(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = localDirectory(root)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
const prepared = { directory, message, files }
+2 -1
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -161,7 +162,7 @@ async function prepareFile(file: RunFilePart) {
if (file.mime !== "text/plain") return { attachment: { uri: file.url, name: file.filename } }
const content = file.url.startsWith("data:")
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
: await Bun.file(new URL(file.url)).text()
: await readFile(new URL(file.url), "utf8")
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
}
+9
View File
@@ -0,0 +1,9 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
process.stdout.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
throw error
})
await import("../index")
@@ -0,0 +1,28 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2/effect"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool,
}
@@ -0,0 +1,26 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/v2"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
}
+34
View File
@@ -0,0 +1,34 @@
const platforms = ["darwin", "linux", "win32"] as const
export type NodeTarget = ReturnType<typeof nodeTarget>
export function nodeTarget(platform: string, arch: string) {
if (!platforms.includes(platform as (typeof platforms)[number]) || (arch !== "arm64" && arch !== "x64")) {
throw new Error(`Unsupported Node executable target: ${platform}-${arch}`)
}
const targetPlatform = platform as (typeof platforms)[number]
const targetArch = arch as "arm64" | "x64"
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
return {
platform: targetPlatform,
arch: targetArch,
nodePtyPackage,
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
parcelWatcherPackage,
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
}
}
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
export const attentionSoundAssets = [
"@opencode-ai/ui/audio/bip-bop-01.mp3",
"@opencode-ai/ui/audio/bip-bop-03.mp3",
"@opencode-ai/ui/audio/staplebops-06.mp3",
"@opencode-ai/ui/audio/nope-03.mp3",
"@opencode-ai/ui/audio/yup-01.mp3",
] as const
+2 -4
View File
@@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import { selfCommand } from "../util/process"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
// points the client package's service operations at this CLI: which
@@ -78,13 +79,10 @@ const paths = Effect.gen(function* () {
export const options = Effect.fnUntraced(function* () {
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
return {
file,
version: InstallationVersion,
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
command: [...selfCommand(), "serve", "--service"],
}
})
+13 -8
View File
@@ -1,10 +1,10 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
import { selfCommand } from "../util/process"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
@@ -14,10 +14,7 @@ type Options = {
}
function command(password: string, options: Options) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: process.cwd(),
@@ -39,8 +36,16 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password, options))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const readyLine = yield* Deferred.make<string, Error>()
// Keep draining stdout after readiness so later server writes cannot hit EPIPE.
yield* proc.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Deferred.succeed(readyLine, line)),
Effect.ensuring(Deferred.fail(readyLine, new Error("Standalone server exited before reporting readiness"))),
Effect.forkScoped,
)
const output = yield* Deferred.await(readyLine)
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return {
url: ready.url,
@@ -7,6 +7,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { go } from "@opencode-ai/tui/logo"
import { setTimeout } from "node:timers/promises"
import {
batch,
createEffect,
@@ -123,7 +124,7 @@ async function open(from?: string): Promise<Session> {
let shownAt = performance.now()
const waitForStage = async () => {
const remaining = stageFloor - (performance.now() - shownAt)
if (remaining > 0) await Bun.sleep(remaining)
if (remaining > 0) await setTimeout(remaining)
}
const advance = async (stage: number) => {
await waitForStage()
@@ -140,11 +141,11 @@ async function open(from?: string): Promise<Session> {
setOutcome(next)
const completed = await Promise.race([
settled.promise.then(() => true),
Bun.sleep(transitionDuration + 500).then(() => false),
setTimeout(transitionDuration + 500).then(() => false),
])
resolveOutcome = undefined
setAnimating(false)
if (completed) await Bun.sleep(hold)
if (completed) await setTimeout(hold)
}
let closing: Promise<void> | undefined
let transferred = false
@@ -154,7 +155,7 @@ async function open(from?: string): Promise<Session> {
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
await Promise.race([renderer.idle(), Bun.sleep(500)])
await Promise.race([renderer.idle(), setTimeout(500)])
renderer.destroy()
})())
let loading: Promise<void> | undefined
@@ -178,7 +179,7 @@ async function open(from?: string): Promise<Session> {
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), Bun.sleep(500)])
await Promise.race([renderer.idle(), setTimeout(500)])
transferred = true
return {
renderer,
+6 -1
View File
@@ -12,11 +12,16 @@ import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import semver from "semver"
declare const OPENCODE_CLI_NAME: string | undefined
export type Policy = boolean | "notify"
export type Action = "none" | "upgrade"
type Method = "npm" | "pnpm" | "bun" | "yarn"
const packageName = "@opencode-ai/cli"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
+5
View File
@@ -0,0 +1,5 @@
import { text } from "node:stream/consumers"
export function readStdin() {
return text(process.stdin)
}
+26
View File
@@ -0,0 +1,26 @@
import path from "node:path"
export function selfCommand() {
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
return [process.execPath, process.argv[1]]
}
function nodeFlags() {
return process.execArgv.flatMap((arg, index, args) => {
if (index > 0 && args[index - 1] === "--conditions") return []
if (arg === "--conditions") return args[index + 1] ? [arg, args[index + 1]] : []
if (arg.startsWith("--conditions=")) return [arg]
if (
arg === "--experimental-ffi" ||
arg === "--use-system-ca" ||
arg === "--enable-source-maps" ||
arg === "--no-addons"
)
return [arg]
if (arg === "--no-warnings" || arg.startsWith("--disable-warning=")) return [arg]
return []
})
}
+2 -4
View File
@@ -191,9 +191,7 @@ test("concurrent service processes elect one server", async () => {
),
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise(
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await winner?.exited
} finally {
processes.forEach((process) => process.kill("SIGTERM"))
@@ -271,7 +269,7 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
),
Effect.filterOrFail((rows) => rows.length > 0),
Effect.map((rows) => rows.length),
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
Effect.retry(Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(200)])),
)
}),
)
+2 -1
View File
@@ -6,5 +6,6 @@
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
},
"exclude": ["dist", "dist-node"]
}
+220
View File
@@ -0,0 +1,220 @@
import path from "node:path"
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
const dir = import.meta.dirname
function rawTextPlugin(): Plugin {
return {
name: "opencode:raw-text",
async load(id) {
if (!id.endsWith(".md")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
enforce: "pre",
transform(code, id) {
if (!id.endsWith("turndown/lib/turndown.es.js")) return
const transformed = code.replace(" var domino = require('@mixmark-io/domino');", "")
if (transformed === code) this.error("Failed to rewrite Turndown's Domino require")
return `import domino from "@mixmark-io/domino"\n${transformed}`
},
}
}
const resolve = {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
{ find: /^solid-js$/, replacement: "solid-js/dist/solid.js" },
{
find: /^ws$/,
replacement: path.join(path.dirname(createRequire(import.meta.url).resolve("ws/package.json")), "wrapper.mjs"),
},
],
conditions: ["node"],
}
const output = (entryFileNames: string, banner?: string) => ({
format: "esm" as const,
entryFileNames,
inlineDynamicImports: true,
banner,
})
function nodePrelude(input: NodeBuildInput) {
const nodePtySpawnHelper =
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
export const Command = sdk.Command
export const Connection = sdk.Connection
export const Credential = sdk.Credential
export const Integration = sdk.Integration
export const Model = sdk.Model
export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Tool = sdk.Tool
export const Failure = sdk.Tool.Failure
export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const withPermission = sdk.Tool.withPermission
export const permission = sdk.Tool.permission
export const definition = sdk.Tool.definition
export const settle = sdk.Tool.settle`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
import { tmpdir as __ocTmpdir } from "node:os"
import __ocPath from "node:path"
import { getAssetKeys as __ocAssetKeys, getRawAsset as __ocRawAsset, isSea as __ocIsSea } from "node:sea"
import { fileURLToPath as __ocFileURLToPath } from "node:url"
const __filename = import.meta.filename
const __dirname = import.meta.dirname
const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
})}
__cjs_mod__.registerHooks({
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
const __ocUrl = __ocPluginModules[__ocSpecifier]
return __ocUrl ? { url: __ocUrl, shortCircuit: true } : __ocNextResolve(__ocSpecifier, __ocContext)
},
load(__ocUrl, __ocContext, __ocNextLoad) {
const __ocSource = __ocPluginSources[__ocUrl]
return __ocSource
? { format: "module", source: __ocSource, shortCircuit: true }
: __ocNextLoad(__ocUrl, __ocContext)
},
})
const __ocUid = typeof process.getuid === "function" ? process.getuid() : undefined
const __ocCacheRoot = __ocPath.join(__ocTmpdir(), \`opencode-node-\${__ocUid ?? "user"}\`)
if (__ocIsSea()) {
try {
__ocMkdir(__ocCacheRoot, { mode: 0o700 })
} catch (__ocError) {
if (!__ocExists(__ocCacheRoot)) throw __ocError
}
const __ocCacheInfo = __ocLstat(__ocCacheRoot)
if (!__ocCacheInfo.isDirectory() || __ocCacheInfo.isSymbolicLink()) throw new Error("Unsafe Node asset cache path")
if (__ocUid !== undefined && __ocCacheInfo.uid !== __ocUid) throw new Error("Node asset cache is owned by another user")
if (__ocUid !== undefined) __ocChmod(__ocCacheRoot, 0o700)
}
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
if (__ocIsSea()) {
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
__ocRm(__ocTemporary, { force: true })
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
}
export type NodeBuildInput = {
readonly version: string
readonly channel: string
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
solid({
solid: {
generate: "universal",
moduleName: "@opentui/solid",
},
}),
],
resolve,
esbuild: { jsx: "automatic" },
define: {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_MODELS_DEV: input.models,
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
},
ssr: { noExternal: true },
build: {
ssr: "src/node/index.ts",
target: "node26",
outDir: "dist-node",
emptyOutDir: false,
minify: true,
rollupOptions: {
external: [/^@opencode-ai\/simulation(?:\/|$)/],
output: output("opencode.mjs", nodePrelude(input)),
},
},
})
}
export default mainConfig({
version: process.env.OPENCODE_VERSION ?? "local",
channel: process.env.OPENCODE_CHANNEL ?? "local",
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
})
+1 -1
View File
@@ -36,7 +36,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.83"
"effect": "4.0.0-beta.98"
},
"peerDependenciesMeta": {
"effect": {
+27 -18
View File
@@ -269,32 +269,40 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
export type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
}
export type Endpoint5_25Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_25Input) => Stream.Stream<Endpoint5_25Output, E>
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.generate"]>>["data"]
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
}
export type Endpoint5_26Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
}
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -328,6 +336,7 @@ export interface SessionApi<E = never> {
readonly remove: SessionInstructionsEntryRemoveOperation<E>
}
}
readonly generate: SessionGenerateOperation<E>
readonly log: SessionLogOperation<E>
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
+30 -18
View File
@@ -361,13 +361,24 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I
Effect.mapError(mapClientError),
)
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
}
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
}
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -378,22 +389,22 @@ const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25I
),
)
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
}
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -421,10 +432,11 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
log: Endpoint5_25(raw),
interrupt: Endpoint5_26(raw),
background: Endpoint5_27(raw),
message: Endpoint5_28(raw),
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
})
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
+5 -12
View File
@@ -3,12 +3,7 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type {
DiscoverOptions,
Endpoint,
EnsureOptions,
StopOptions,
} from "../service.js"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -82,7 +77,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
yield* kill(service, options).pipe(Effect.ignore)
@@ -221,7 +217,7 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))
const poll = Schedule.max([Schedule.spaced("50 millis"), Schedule.recurs(100)])
const signal = (pid: number, name: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
@@ -238,10 +234,7 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
) {
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
const requested = yield* requestStop(service)
if (requested === "rejected") return
if (requested === "unsupported") {
@@ -58,6 +58,8 @@ import type {
SessionInstructionsEntryPutOutput,
SessionInstructionsEntryRemoveInput,
SessionInstructionsEntryRemoveOutput,
SessionGenerateInput,
SessionGenerateOutput,
SessionLogInput,
SessionLogOutput,
SessionInterruptInput,
@@ -743,6 +745,18 @@ export function make(options: ClientOptions) {
),
},
},
generate: (input: SessionGenerateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGenerateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
body: { prompt: input["prompt"] },
successStatus: 200,
declaredStatuses: [404, 503, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
sse<SessionLogOutput>(
{
@@ -134,6 +134,8 @@ export type SessionMessageCompactionCompleted = {
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -3187,6 +3189,13 @@ export type SessionInstructionsEntryRemoveInput = {
export type SessionInstructionsEntryRemoveOutput = void
export type SessionGenerateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly prompt: { readonly prompt: string }["prompt"]
}
export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
+4
View File
@@ -379,6 +379,7 @@ test("session methods use the public HTTP contract", async () => {
})
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/generate")) return Response.json({ data: { text: "A transient answer" } })
if (url.includes("/synthetic")) return Response.json(syntheticAdmission)
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
if (url.includes("/context")) return Response.json({ data: [] })
@@ -403,6 +404,7 @@ test("session methods use the public HTTP contract", async () => {
text: "Hello",
resume: false,
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
const synthetic = await client.session.synthetic({
sessionID: "ses_test",
text: "Completed",
@@ -421,6 +423,7 @@ test("session methods use the public HTTP contract", async () => {
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced])
@@ -432,6 +435,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/generate"],
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
+16
View File
@@ -37,6 +37,21 @@
"bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts"
},
"#photon-wasm": {
"bun": "./src/image/photon-wasm.bun.ts",
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#runtime-import": {
"bun": "./src/runtime/import.bun.ts",
"node": "./src/runtime/import.node.ts",
"default": "./src/runtime/import.bun.ts"
},
"#process-lock-ffi": {
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
}
},
"devDependencies": {
@@ -121,6 +136,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"resolve.exports": "catalog:",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
+19 -1
View File
@@ -12,6 +12,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { PermissionV2 } from "../../permission"
import { SHELL_OUTPUT_GLOB } from "../../permission/defaults"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
@@ -112,6 +113,23 @@ export const Plugin = define({
})
}
}
// Internal shell output remains readable through broad external-directory denials.
// An exact deny still lets users explicitly revoke access to these files.
for (const current of draft.list()) {
draft.update(current.id, (agent) => {
const denied = agent.permissions.some(
(rule) =>
rule.action === "external_directory" && rule.resource === SHELL_OUTPUT_GLOB && rule.effect === "deny",
)
if (
denied ||
PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, agent.permissions).effect === "allow"
)
return
agent.permissions.push({ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" })
})
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
@@ -150,7 +168,7 @@ function expandHome(resource: string, home: string) {
function discover(fs: FSUtil.Interface, directory: string) {
return Effect.forEach(legacySources, (source) =>
fs
.glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
.scan(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(
Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary }))),
),
+1 -1
View File
@@ -62,7 +62,7 @@ export const Plugin = define({
function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.scan("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
return yield* Effect.forEach(files.toSorted(), (filepath) =>
fs.readFileStringSafe(filepath).pipe(
+46 -28
View File
@@ -9,6 +9,8 @@ import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Global } from "../../global"
import { Location } from "../../location"
import { allowExternalDirectories } from "../../permission/defaults"
import { Repository } from "../../repository"
export const Plugin = define({
id: "opencode.config.reference",
@@ -18,35 +20,20 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
for (const [name, source] of sources(loaded.entries, location.directory, global.home)) draft.add(name, source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
Array.from(sources(loaded.entries, location.directory, global.home)).flatMap(([, source]) => {
if (source.type === "local") return [path.join(source.path, "*")]
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) return []
return [path.join(Repository.cachePath(global.repos, repository), "*")]
}),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
@@ -54,6 +41,7 @@ export const Plugin = define({
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
@@ -61,6 +49,36 @@ export const Plugin = define({
}),
})
function sources(entries: readonly Config.Entry[], location: string, home: string) {
const result = new Map<string, Reference.Source>()
for (const doc of entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
result.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(localPath(directory, home, typeof entry === "string" ? entry : entry.path)),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
}
return result
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
+50 -35
View File
@@ -8,6 +8,8 @@ import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
import { Global } from "../../global"
import { Location } from "../../location"
import { SkillDiscovery } from "../../skill/discovery"
import { allowExternalDirectories } from "../../permission/defaults"
export const Plugin = define({
id: "opencode.config.skill",
@@ -17,41 +19,19 @@ export const Plugin = define({
const location = yield* Location.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
for (const source of sources(loaded.entries, global.home, location.directory)) draft.source(source)
})
yield* ctx.agent.transform((draft) => {
const permissions = allowExternalDirectories(
sources(loaded.entries, global.home, location.directory).map((source) =>
path.join(
source.type === "directory" ? source.path : SkillDiscovery.cachePath(global.cache, source.url),
"*",
),
),
)
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
})
yield* ctx.event.subscribe().pipe(
@@ -60,9 +40,44 @@ export const Plugin = define({
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function sources(entries: readonly Config.Entry[], home: string, directory: string) {
const result: Array<SkillV2.DirectorySource | SkillV2.UrlSource> = []
for (const entry of entries) {
if (entry.type === "claude" || entry.type === "agents") {
result.push(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
)
continue
}
if (entry.type === "directory") {
result.push(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skill")) }),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
)
continue
}
if (entry.type !== "document") continue
for (const item of entry.info.skills ?? []) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
result.push(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(home, item.slice(2)) : item
result.push(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)),
}),
)
}
}
return result
}
+3
View File
@@ -88,6 +88,9 @@ const make = (options: Config) =>
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
@@ -89,6 +89,9 @@ const make = (options: Config) =>
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
+4 -1
View File
@@ -11,10 +11,12 @@ import { Flag } from "../flag/flag"
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
const require = createRequire(import.meta.url)
export const Event = { Updated: FileSystem.Event.Changed }
@@ -22,7 +24,8 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch {
+5 -5
View File
@@ -42,7 +42,7 @@ export namespace FSUtil {
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly scan: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}
@@ -51,7 +51,7 @@ export namespace FSUtil {
export const use = serviceUse(Service)
// Exported so simulation can wrap this layer and override the methods that
// bypass the injected FileSystem (readDirectoryEntries, glob, globUp).
// bypass the injected FileSystem (readDirectoryEntries, scan, globUp).
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -146,7 +146,7 @@ export namespace FSUtil {
if (mode) yield* fs.chmod(path, mode)
})
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
const scan = Effect.fn("FileSystem.scan")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
@@ -187,7 +187,7 @@ export namespace FSUtil {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
const matches = yield* scan(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
@@ -214,7 +214,7 @@ export namespace FSUtil {
findUp,
up,
globUp,
glob,
scan,
globMatch: Glob.match,
})
}),
@@ -0,0 +1,4 @@
// @ts-ignore Bun embeds static file imports when compiling the CLI.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
export default photonWasm
@@ -0,0 +1,4 @@
import { createRequire } from "node:module"
export default process.env.OPENCODE_PHOTON_WASM_PATH ??
createRequire(import.meta.url).resolve("@silvia-odwyer/photon-node/photon_rs_bg.wasm")
+1 -2
View File
@@ -1,5 +1,4 @@
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import photonWasm from "#photon-wasm"
import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
+1 -1
View File
@@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
const entries = (servers: ReadonlyArray<Summary>) =>
servers.flatMap((server) => [
` <server name="${server.server}">`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
...server.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
])
+18
View File
@@ -0,0 +1,18 @@
declare module "node:ffi" {
type Signature = {
readonly arguments?: readonly string[]
readonly return?: string
}
type ForeignFunction = (...args: ReadonlyArray<unknown>) => number | bigint
export function dlopen(
path: string,
definitions: Readonly<Record<string, Signature>>,
): {
readonly lib: { close(): void }
readonly functions: Readonly<Record<string, ForeignFunction>>
}
export function getInt32(pointer: number | bigint, offset?: number): number
}
+2 -3
View File
@@ -12,6 +12,7 @@ import { filesystem } from "./effect/app-node-platform"
import { LayerNode } from "./effect/layer-node"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
import { resolveModule } from "#runtime-import"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
@@ -54,9 +55,7 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[
const entrypoint = subpaths
.map((subpath) => {
try {
return typeof Bun !== "undefined"
? import.meta.resolve([name, subpath].filter(Boolean).join("/"), dir)
: import.meta.resolve(dir)
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
} catch {
return undefined
}
+10
View File
@@ -0,0 +1,10 @@
import path from "path"
import { Global } from "../global"
import { PermissionV2 } from "../permission"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
export const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
export function allowExternalDirectories(resources: readonly string[]): PermissionV2.Ruleset {
return resources.map((resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }))
}
+1 -3
View File
@@ -7,10 +7,8 @@ import { AgentV2 } from "../agent"
import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { SHELL_OUTPUT_GLOB } from "../permission/defaults"
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
+1
View File
@@ -424,6 +424,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
synthetic: runtime.session.synthetic,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
+1 -1
View File
@@ -147,9 +147,9 @@ const pre = [
const post = [
ConfigReferencePlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigAgentPlugin.Plugin,
ConfigCommandPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
+2
View File
@@ -231,6 +231,8 @@ export function fromPromise(plugin: Plugin) {
resume: input.resume ?? undefined,
}),
),
generate: (input) =>
run(host.session.generate({ sessionID: Session.ID.make(input.sessionID), prompt: input.prompt })),
command: (input) =>
run(
host.session.command({
+4 -5
View File
@@ -2,6 +2,7 @@ import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { importModule } from "#runtime-import"
export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
@@ -17,11 +18,9 @@ export const DynamicProviderPlugin = define({
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
const mod = yield* Effect.promise(async () => {
return (await import(
installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href
)) as Record<string, (options: any) => any>
}).pipe(Effect.orDie)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
@@ -3,6 +3,7 @@ import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider"
import { importModule } from "#runtime-import"
export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
@@ -22,9 +23,9 @@ export const SapAICorePlugin = define({
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
const mod: Record<string, unknown> = yield* Effect.promise(
() => import(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
const factory = mod[match]
+2 -1
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface {
readonly session: Pick<
SessionV2.Interface,
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -50,6 +50,7 @@ export const layerWithCell = (cell: Cell) =>
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
+1 -1
View File
@@ -94,7 +94,7 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
}
if (entry.type !== "directory") return Effect.succeed([])
return fs
.glob("{plugin,plugins}/*.{ts,js}", {
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
+8 -3
View File
@@ -39,6 +39,7 @@ import { WellKnown } from "../wellknown"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
import { importModule } from "#runtime-import"
const PluginModule = Schema.Struct({
default: Schema.Union([
@@ -164,9 +165,13 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined ? entrypoint : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
operation.mtime === undefined
? entrypoint
: typeof Bun !== "undefined"
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
@@ -179,7 +184,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.glob("{plugin,plugins}/*.{ts,js}", {
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
+25 -4
View File
@@ -1,11 +1,11 @@
export * as ProviderV2 from "./provider"
import { Effect, Schema } from "effect"
import { pathToFileURL } from "url"
import { Provider } from "@opencode-ai/schema/provider"
import type { ProviderPackageDefinition } from "@opencode-ai/ai"
import { Npm } from "./npm"
import type { DeepMutable } from "./schema"
import { importModule, resolveModule } from "#runtime-import"
export const ID = Provider.ID
export type ID = typeof ID.Type
@@ -32,8 +32,24 @@ export class LoadError extends Schema.TaggedErrorClass<LoadError>()("ProviderV2.
export type ProviderPackage = ProviderPackageDefinition
const packages = new Map<string, Promise<unknown>>()
const builtins = new Map<string, () => Promise<unknown>>([
["@opencode-ai/ai/providers/amazon-bedrock", () => import("@opencode-ai/ai/providers/amazon-bedrock")],
["@opencode-ai/ai/providers/anthropic", () => import("@opencode-ai/ai/providers/anthropic")],
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
["@opencode-ai/ai/providers/openai-compatible", () => import("@opencode-ai/ai/providers/openai-compatible")],
["@opencode-ai/ai/providers/openrouter", () => import("@opencode-ai/ai/providers/openrouter")],
["@opencode-ai/ai/providers/xai", () => import("@opencode-ai/ai/providers/xai")],
])
export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specifier: string, npm?: Npm.Interface) {
const builtin = builtins.get(specifier)
if (builtin) return yield* importPackage(specifier, specifier, builtin)
const resolved = yield* Effect.sync(() => {
if (specifier.startsWith("file://") || specifier.startsWith("@opencode-ai/ai/")) return specifier
try {
@@ -53,7 +69,8 @@ export const loadPackage = Effect.fn("ProviderV2.loadPackage")(function* (specif
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () => import.meta.resolve(specifier, pathToFileURL(`${installed.directory}/`).href),
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
@@ -116,12 +133,16 @@ export type Info = Provider.Info
export type MutableInfo = DeepMutable<Info>
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (specifier: string, entrypoint: string) {
const importPackage = Effect.fn("ProviderV2.importPackage")(function* (
specifier: string,
entrypoint: string,
load = () => importModule(entrypoint),
) {
const module = yield* Effect.tryPromise({
try: () => {
const existing = packages.get(entrypoint)
if (existing) return existing
const loaded = import(entrypoint)
const loaded = load()
packages.set(entrypoint, loaded)
return loaded
},
+7 -3
View File
@@ -1,11 +1,15 @@
// ast-grep-ignore: no-star-import
import * as pty from "@lydell/node-pty"
import { createRequire } from "node:module"
import { isSea } from "node:sea"
import type { Opts, Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
const pty = createRequire(import.meta.url)(
process.env.OPENCODE_NODE_PTY_PATH ?? "@lydell/node-pty",
) as typeof import("@lydell/node-pty")
export function spawn(file: string, args: string[], opts: Opts): Proc {
const proc = pty.spawn(file, args, opts)
const proc = pty.spawn(file, args, process.platform === "win32" && isSea() ? { ...opts, useConptyDll: true } : opts)
return {
pid: proc.pid,
onData(listener) {
+7
View File
@@ -0,0 +1,7 @@
export function importModule(specifier: string) {
return import(specifier) as Promise<unknown>
}
export function resolveModule(specifier: string, directory: string) {
return import.meta.resolve(specifier, directory)
}
+39
View File
@@ -0,0 +1,39 @@
import { Script, constants } from "node:vm"
import { createRequire, registerHooks } from "node:module"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { resolve, type Package } from "resolve.exports"
let conditions: readonly string[] = []
const conditionHooks = registerHooks({
resolve(specifier, context, nextResolve) {
conditions = context.conditions
return nextResolve(specifier, context)
},
})
await new Script('import("node:module")', {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()
conditionHooks.deregister()
export async function importModule(specifier: string) {
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()) as unknown
if (typeof imported !== "object" || imported === null) return imported
const module = imported as Record<string, unknown>
const exports = module["module.exports"]
if (exports !== module.default || (typeof exports !== "object" && typeof exports !== "function") || exports === null)
return imported
return Object.assign({}, module, exports)
}
export function resolveModule(specifier: string, directory: string) {
const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package
const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0]
if (target) return pathToFileURL(path.resolve(directory, target)).href
const legacyTarget =
specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1))
return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href
}
+2 -3
View File
@@ -44,12 +44,11 @@ const retryAfter = (failure: RetryableFailure) => {
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.exponential("2 seconds").pipe(
Schedule.take(4),
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay((failure, delay) => {
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
@@ -206,7 +206,7 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`,
content: `The following shell command was executed by the user:\n\nCommand:\n${message.command}\n\nOutput:\n${message.output?.output ?? ""}`,
metadata: message.metadata,
}),
]
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) {
const files = yield* fs
.glob("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+6 -1
View File
@@ -8,6 +8,7 @@ import { Global } from "../global"
import { makeGlobalNode } from "../effect/app-node"
import { httpClient } from "../effect/app-node-platform"
import { AbsolutePath } from "../schema"
import { Hash } from "../util/hash"
const skillConcurrency = 4
const fileConcurrency = 8
@@ -68,6 +69,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
export function cachePath(cache: string, url: string) {
return path.resolve(cache, "skills", Hash.fast(url.endsWith("/") ? url : `${url}/`))
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -110,7 +115,7 @@ const layer = Layer.effect(
)
if (!data) return []
const sourceRoot = path.resolve(global.cache, "skills", Bun.hash(base).toString(16))
const sourceRoot = cachePath(global.cache, base)
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
+1 -1
View File
@@ -29,7 +29,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
+4 -20
View File
@@ -39,7 +39,7 @@ type CollectedFiles = {
interface Registration {
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly namespace?: string
}
export const create = (registrations: ReadonlyMap<string, Registration>) => {
@@ -47,7 +47,7 @@ export const create = (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> | Record<string, Tool.Definition<never>>> = {}
const tools: Record<string, Tool.Definition<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
@@ -56,24 +56,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
output: child.outputSchema,
run: (input) => invoke(name, registration, input),
})
if (registration.group === undefined) {
const path = registration.name
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
tools[path] = value
continue
}
const path = registration.name
const namespace = registration.group
const group = tools[namespace]
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
if (group) {
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
group[path] = value
continue
}
const entries: Record<string, Tool.Definition<never>> = {}
entries[path] = value
tools[namespace] = entries
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = value
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
+5 -4
View File
@@ -13,10 +13,11 @@ import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
/**
* Registry group and permission action names for MCP tools.
* Registry namespace and permission action names for MCP tools.
*/
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
export const name = (server: string, tool: string) =>
`${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -107,7 +108,7 @@ export const layer = Layer.effectDiscard(
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([group, record]) => tools.register(record, { group }),
([server, record]) => tools.register(record, { namespace: namespace(server) }),
{
discard: true,
},
+13 -4
View File
@@ -10,7 +10,15 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ExecuteTool } from "./execute"
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
import {
definition,
permission,
registrationEntries,
RegistrationError,
settle,
validateNamespace,
type AnyTool,
} from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
@@ -95,7 +103,7 @@ const registryLayer = Layer.effect(
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly group?: string
readonly namespace?: string
readonly codemode: boolean
}
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
@@ -186,7 +194,8 @@ const registryLayer = Layer.effect(
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.group)
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
@@ -205,7 +214,7 @@ const registryLayer = Layer.effect(
registration: {
tool: entry.tool,
name: entry.name,
group: entry.group,
namespace: entry.namespace,
codemode,
},
},
+1 -1
View File
@@ -84,7 +84,7 @@ export const Plugin = {
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
+8 -5
View File
@@ -50,11 +50,14 @@ export namespace EffectFlock {
const BASE_DELAY_MS = 100
const MAX_DELAY_MS = 2_000
const retrySchedule = (timeoutMs: number) => Schedule.exponential(BASE_DELAY_MS, 1.7).pipe(
Schedule.either(Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10))))),
Schedule.jittered,
Schedule.while((meta) => meta.elapsed < timeoutMs),
)
const retrySchedule = (timeoutMs: number) =>
Schedule.min([
Schedule.exponential(BASE_DELAY_MS, 1.7),
Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10)))),
]).pipe(
Schedule.jittered,
Schedule.while((meta) => meta.elapsed < timeoutMs),
)
// ---------------------------------------------------------------------------
// Lock metadata schema
@@ -0,0 +1,50 @@
import { dlopen, read, type Pointer } from "bun:ffi"
import { existsSync } from "node:fs"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { args: ["i32", "i32"], returns: "i32" },
__error: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
export function lockLinux(fd: number): LockResult {
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
flock: { args: ["i32", "i32"], returns: "i32" },
__errno_location: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function errorCode(pointer: Pointer | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}
@@ -0,0 +1,43 @@
import { dlopen, getInt32 } from "node:ffi"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__error: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0)
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}
export function lockLinux(fd: number): LockResult {
const library = dlopen("libc.so.6", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__errno_location: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0)
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}
+3 -51
View File
@@ -1,5 +1,5 @@
import { dlopen, read, type Pointer } from "bun:ffi"
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs"
import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi"
import { closeSync, mkdirSync, openSync } from "node:fs"
import { connect, createServer, type Server, type Socket } from "node:net"
import path from "node:path"
import { Effect, Schema } from "effect"
@@ -76,60 +76,12 @@ export namespace ProcessLock {
})
}
type Result =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
function lock(fd: number): Result {
function lock(fd: number): LockResult {
if (process.platform === "darwin") return lockDarwin(fd)
if (process.platform === "linux") return lockLinux(fd)
throw new Error(`Unsupported process lock platform: ${process.platform}`)
}
function lockDarwin(fd: number): Result {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { args: ["i32", "i32"], returns: "i32" },
__error: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function lockLinux(fd: number): Result {
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
flock: { args: ["i32", "i32"], returns: "i32" },
__errno_location: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function errorCode(pointer: Pointer | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}
function acquireWindows(file: string) {
return Effect.callback<Server, ProcessLock.LockError>((resume) => {
const server = createServer()
+61 -2
View File
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SHELL_OUTPUT_GLOB } from "@opencode-ai/core/permission/defaults"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
@@ -22,6 +23,11 @@ const defaultPermissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
] satisfies PermissionV2.Ruleset
const shellOutputPermission = {
action: "external_directory",
resource: SHELL_OUTPUT_GLOB,
effect: "allow",
} satisfies PermissionV2.Rule
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
@@ -114,6 +120,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
shellOutputPermission,
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
@@ -132,6 +139,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
shellOutputPermission,
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
@@ -139,11 +147,31 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
shellOutputPermission,
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
)
it.effect("keeps shell output readable through a broad external-directory deny", () =>
Effect.gen(function* () {
const permissions = yield* loadConfiguredPermissions([
{ action: "external_directory", resource: "*", effect: "deny" },
])
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("allow")
}),
)
it.effect("respects an exact shell output deny", () =>
Effect.gen(function* () {
const permissions = yield* loadConfiguredPermissions([
{ action: "external_directory", resource: "*", effect: "deny" },
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "deny" },
])
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("deny")
}),
)
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
@@ -294,13 +322,21 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [
...defaultPermissions,
{ action: "edit", resource: "*", effect: "deny" },
shellOutputPermission,
],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
permissions: [
...defaultPermissions,
{ action: "edit", resource: "*", effect: "deny" },
shellOutputPermission,
],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
@@ -357,3 +393,26 @@ function loadHomePermissions(home: string) {
return agent.permissions
})
}
function loadConfiguredPermissions(permissions: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((draft) => draft.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ permissions }),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
)
const agent = yield* agents.get(build)
if (!agent) throw new Error("expected configured build agent")
return agent.permissions
})
}
+2
View File
@@ -34,8 +34,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
return { dispose }
})
const agent = host().agent
yield* ConfigSkillPlugin.Plugin.effect(
host({
agent: { ...agent, transform: () => Effect.succeed({ dispose: Effect.void }) },
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
@@ -280,7 +280,7 @@ describe("FSUtil", () => {
yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b")
yield* filesys.writeFileString(path.join(tmp, "c.json"), "c")
const result = yield* fs.glob("*.ts", { cwd: tmp })
const result = yield* fs.scan("*.ts", { cwd: tmp })
expect(result.sort()).toEqual(["a.ts", "b.ts"])
}),
)
@@ -293,7 +293,7 @@ describe("FSUtil", () => {
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
const result = yield* fs.scan("*.txt", { cwd: tmp, absolute: true })
expect(result).toEqual([path.join(tmp, "file.txt")])
}),
)
+65
View File
@@ -18,6 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
@@ -112,6 +113,70 @@ describe("LocationServiceMap", () => {
),
)
it.live("allows external skill and reference directories by default", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([project, external]) =>
Effect.gen(function* () {
const skill = path.join(external.path, "skills", "example")
const reference = path.join(external.path, "reference")
yield* Effect.promise(() =>
Promise.all([
fs.mkdir(skill, { recursive: true }),
fs.mkdir(reference, { recursive: true }),
fs.writeFile(
path.join(project.path, "opencode.json"),
JSON.stringify({
skills: [path.join(external.path, "skills")],
references: { docs: reference },
}),
),
]),
)
yield* Effect.promise(() =>
fs.writeFile(
path.join(skill, "SKILL.md"),
"---\nname: example\ndescription: Example skill.\n---\n\n# Example\n",
),
)
const locations = yield* LocationServiceMap.Service
const context = locations.get(Location.Ref.make({ directory: AbsolutePath.make(project.path) }))
const permissions = yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const agents = yield* AgentV2.Service
yield* supervisor.flush
for (let attempt = 0; attempt < 100; attempt++) {
const build = yield* agents.resolve("build")
if (
build &&
PermissionV2.evaluate(
"external_directory",
path.join(skill, "reference", "notes.md"),
build.permissions,
).effect === "allow" &&
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), build.permissions)
.effect === "allow"
)
return build.permissions
yield* Effect.sleep("20 millis")
}
return (yield* agents.resolve("build"))?.permissions ?? []
}).pipe(Effect.scoped, Effect.provide(context))
expect(
PermissionV2.evaluate("external_directory", path.join(skill, "reference", "notes.md"), permissions).effect,
).toBe("allow")
expect(
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), permissions).effect,
).toBe("allow")
}),
),
),
)
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+1
View File
@@ -268,6 +268,7 @@ describe("MCP errors", () => {
})
test("MCP tool names match V1 sanitization", () => {
expect(McpTool.namespace("context 7")).toBe("context_7")
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
+4 -4
View File
@@ -274,7 +274,7 @@ describe("PluginV2", () => {
}),
)
it.effect("groups tool names and routes codemode registrations through execute", () =>
it.effect("namespaces tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
@@ -291,8 +291,8 @@ describe("PluginV2", () => {
ctx.tool
.transform((draft) => {
draft.add("plain", tool("Plain"), { codemode: false })
draft.add("look/up", tool("Lookup"), { group: "context 7", codemode: false })
draft.add("search", tool("Search"), { group: "context 7" })
draft.add("look/up", tool("Lookup"), { namespace: "context7", codemode: false })
draft.add("search", tool("Search"), { namespace: "context7" })
})
.pipe(Effect.orDie),
})
@@ -301,7 +301,7 @@ describe("PluginV2", () => {
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([
"plain",
"context_7_look_up",
"context7_look_up",
"execute",
])
}),
+1
View File
@@ -96,6 +96,7 @@ export function host(overrides: Overrides = {}): PluginContext {
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
+21
View File
@@ -24,6 +24,27 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("forwards transient session generation", () =>
Effect.gen(function* () {
const host = testHost({
session: {
generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
},
})
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-session-generate",
setup: async (ctx) => {
expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
text: "ses_generate: Summarize",
})
},
}),
).effect(host)
}),
)
it.effect("forwards synthetic session input", () =>
Effect.gen(function* () {
const input = {
@@ -124,7 +124,12 @@ describe("toLLMMessages", () => {
)
expect(messages.slice(2).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[
{
type: "text",
text: "The following shell command was executed by the user:\n\nCommand:\npwd\n\nOutput:\n/project",
},
],
[
{
type: "text",
@@ -84,6 +84,17 @@ const constant = (text: string) =>
})
describe("ToolRegistry", () => {
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const error = yield* service.register({ echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.materialize()).definitions).toEqual([])
}),
)
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+47
View File
@@ -46,3 +46,50 @@ test("execute preserves successful results with visible unhandled rejections", a
},
])
})
test("execute supports callable namespace tools", async () => {
const callable = Tool.make({
description: "Administer Slack",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("admin"),
})
const child = Tool.make({
description: "Create a Slack resource",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("created"),
})
const execute = ExecuteTool.create(
new Map([
["slack_admin", { tool: callable, name: "admin", namespace: "slack" }],
["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }],
]),
)
const result = await Effect.runPromise(
Tool.settle(
execute,
{
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
},
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)
expect(result.structured).toEqual({
toolCalls: [
{ tool: "slack.admin", status: "completed" },
{ tool: "slack.admin.create", status: "completed" },
],
})
expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
})
+3
View File
@@ -112,6 +112,9 @@ export const make = (
executeValues(sql, params) {
return runValues(sql, params)
},
executeValuesUnprepared(sql, params) {
return runValues(sql, params)
},
executeUnprepared(sql, params, transformRows) {
return this.execute(sql, params, transformRows)
},
+1 -1
View File
@@ -52,7 +52,7 @@
"typescript": "catalog:"
},
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.83"
"@effect/platform-node-shared": "4.0.0-beta.98"
},
"peerDependencies": {
"effect": "catalog:"

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