Compare commits

..
Author SHA1 Message Date
Shoubhit Dash 6b61e05776 refactor(core): isolate session MCP registry behind a session view 2026-09-30 20:15:17 +05:30
Shoubhit Dash 704ef1c693 feat(acp): register MCP servers per session 2026-09-30 19:45:26 +05:30
Shoubhit Dash 2f0a2b607d feat(core): session-scoped MCP servers 2026-09-30 19:45:25 +05:30
James Long 02c3891d9b feat(core): let form cancellation carry a message (#52137) 2026-09-30 09:20:26 -04:00
Shoubhit Dash 1844c11104 fix(acp): follow server defaults and refresh the session catalog (#52286) 2026-09-30 18:36:56 +05:30
opencode-agent[bot] c0285b457a chore(core): refresh bundled models.dev snapshot 2026-09-30 12:25:26 +00:00
opencode-agent[bot]andBrendonovich ffa4c4c730 feat(app): open referenced session IDs from markdown (#52231)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-30 15:39:30 +08:00
Luke Parker 01efd22b2a feat(desktop): comment on page elements from the in-app browser (#52217) 2026-09-30 16:52:42 +10:00
opencode-agent[bot]andBrendonovich dd68d6eb79 fix(app): select new-session project by resolved ID (#52230)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-30 14:34:05 +08:00
Simon Klee 1243d09dbc tui: update OpenTUI to 0.5.13 (#52233) 2026-09-30 06:13:28 +00:00
Aiden Cline a80a00011d fix(ai): classify Workers AI context window rejections as context overflow (#52128) 2026-09-29 23:49:03 -05:00
opencode-agent[bot]andBrendonovich bf97a0443f fix(app): alert only for open session tabs (#52204)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-30 03:55:01 +00:00
Luke Parker 67b14e9191 feat(session-ui): keep open Used group header sticky (#52210) 2026-09-30 13:46:40 +10:00
Luke Parker 1b02abcfab feat(session-ui): group adjacent reads into one row (#52207) 2026-09-30 12:54:40 +10:00
Aiden Cline 74dbc509d7 fix(ai): place prompt cache breakpoints on OpenRouter Anthropic and Qwen requests (#52110) 2026-09-29 18:07:49 -05:00
Aiden Cline 760f87fab1 fix(core): pass through Copilot Responses settings (#52182) 2026-09-29 17:52:53 -05:00
opencode-agent[bot]andrekram1-node df51166711 fix(ci): run V2 Discord notification after skipped build jobs (#52012)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-29 16:22:16 -05:00
opencode 8d8a7bc844 sync release versions for v2.0.20 2026-09-29 21:08:02 +00:00
opencode-agent[bot] a2fc1968be fix(stats): match document background to page theme (#52169) 2026-09-29 15:59:22 -05:00
Aiden Cline c2a39ac592 fix(core): stop promising a ChatGPT usage reset (#52168) 2026-09-29 15:50:09 -05:00
Aiden Cline 4deda18037 fix(app): align ChatGPT usage copy and links (#52163) 2026-09-29 15:18:45 -05:00
Aiden Cline 0f83038253 fix(core): align ChatGPT token sharing with updated partner guide (#52162) 2026-09-29 15:11:58 -05:00
Dax 4c33a253aa feat(cli): add auth export and auth import commands (#52139) 2026-09-29 16:09:01 -04:00
135995d4fc test: stabilize V2 CI test suites (#52159)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-29 14:54:11 -05:00
Aiden Cline 4d9090790b feat(core): add separate ChatGPT token-sharing OAuth method (#52147) 2026-09-29 14:48:51 -05:00
Aiden Cline 621a5b0706 feat(app): show ChatGPT token-sharing guidance (#52160) 2026-09-29 14:45:59 -05:00
Aiden Cline 16ef62c851 feat(app): show ChatGPT usage limit dialog (#52153) 2026-09-29 14:38:48 -05:00
Aiden Cline b694d2bf26 fix(core): explain ChatGPT token-sharing errors (#52158) 2026-09-29 14:25:01 -05:00
Aiden Cline 89d37a0f6d fix(core): restrict database files to their owner (#52150) 2026-09-29 14:18:40 -05:00
James Long bc5ff1cfd9 fix(cli): answer subagent asks in run and keep parallel rejections continuing (#52146) 2026-09-29 15:03:06 -04:00
Aiden Cline c14a1f5acf feat(core): preserve provider response body in session errors (#52136) 2026-09-29 13:32:09 -05:00
Frank 7db941a892 docs(console): add GPT 6.1 Sol to model list 2026-09-29 14:28:47 -04:00
James Long 83fafda63a feat(cli): continue run after rejecting a permission ask (#51968) 2026-09-29 14:26:20 -04:00
opencode-agent[bot]andjlongster a565ea8c74 feat(cli): allow disabling the background service (#52107)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-29 13:32:38 -04:00
Aiden Cline 3126808e0e fix(ai): defer Mistral thinking metadata until block end (#52008) 2026-09-29 11:38:06 -05:00
209 changed files with 7899 additions and 1853 deletions
+1 -1
View File
@@ -662,7 +662,7 @@ jobs:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
+51 -52
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.19",
"version": "2.0.20",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -178,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -204,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -377,6 +377,7 @@
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
@@ -409,7 +410,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -458,7 +459,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -495,7 +496,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -511,7 +512,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -530,7 +531,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -543,7 +544,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -557,7 +558,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -572,7 +573,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -598,8 +599,8 @@
},
"peerDependencies": {
"@opencode/theme": "workspace:*",
"@opentui/core": ">=0.5.12",
"@opentui/solid": ">=0.5.12",
"@opentui/core": ">=0.5.13",
"@opentui/solid": ">=0.5.13",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -611,7 +612,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -641,7 +642,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -656,7 +657,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -680,7 +681,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -701,7 +702,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -723,7 +724,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -758,7 +759,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -778,7 +779,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -812,7 +813,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -831,7 +832,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -877,7 +878,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -891,7 +892,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +926,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +961,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -998,7 +999,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1039,7 +1040,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -1116,9 +1117,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@opentui/core": "0.5.13",
"@opentui/keymap": "0.5.13",
"@opentui/solid": "0.5.13",
"@pierre/diffs": "1.5.1",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2254,27 +2255,25 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
"@opentui/core": ["@opentui/core@0.5.13", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.13", "@opentui/core-darwin-x64": "0.5.13", "@opentui/core-linux-arm64": "0.5.13", "@opentui/core-linux-arm64-musl": "0.5.13", "@opentui/core-linux-x64": "0.5.13", "@opentui/core-linux-x64-musl": "0.5.13", "@opentui/core-win32-arm64": "0.5.13", "@opentui/core-win32-x64": "0.5.13" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-3XNXJx3J9I+Ce170oh7GJ3leBZZA7QYv4OAhwiMCPQtKMkhy3Wra99NM26HXcEGme0zjkkJLownwLLnxuc0U/Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5X9U2v5qn4+vKj31zpn0RMoBV3Q9ttdM4t5KN41NPg6XgtIOZgKwox5yOsvBaiQ1KGbuk5wMvXCxfWJ30Mj/nw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-9IgQHLKSn3quVsU0gibkRjOPegv1QH4573KWYXQNDmSxBXw84Nd1mDNsbK9f72SblAILhOCYK685pmk3X/z0Ig=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-jMXGEpB/2Ch/IR0QzYBM5eCr1tUzicxoTP1AbMCbOduJXcGRCHTWqIdQIgRErwPF92gBcDIs4JugSn/Jd0Ki7g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-7ytcKy7pQxvQer3PADGYNK+HRhSDyhZvp475GRBEhlBJIdhdL8PNU6hjd2R1dnieifiW7Mpc4EiC1kGKe69gYg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-C2ZC7WA7YH/GS+rJnbeVGe6Kxeor0aTD2qcS6l32KmN4XuY7TXm192XJMfxxnKaTA6oKp3mW27ezolJKUcGtMw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-IB6eLh6I/gYXrJ0AYxZpD+Dr+yfZJck0ZfLnIapbD+fykaT0umOtvc/BL+CtlC/aSc7jpe0q4eXdn6XIA8HtLw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-FCucDN8A4iFDPdwqLK99ISs42pcbcObkxhN1jtsnmcYM9/Ptron7Hhapx2ObQWYjSrJNnntmfZQd2ewpE8YqTQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.13", "", { "dependencies": { "@opentui/core": "0.5.13" }, "peerDependencies": { "@opentui/react": "0.5.13", "@opentui/solid": "0.5.13", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-bbpQ5N8000CuvVDhptGuHxUE92sivChjUjLkvYT1VnLlDTiGbQ4mPsHfnqsaEokc7O7jBcoJXJQsLxw7rO/qkg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "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-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
"@opentui/solid": ["@opentui/solid@0.5.13", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.13", "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-MGMYQYeoZNn9uq3zsLPqq/G+nBQXgGwpylsrYux0nJ+C+6oflDEE6t8huSTZaAUizC/3WG8sVSVFutczeRbnPg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
@@ -52,9 +52,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.12",
"@opentui/keymap": "0.5.12",
"@opentui/solid": "0.5.12",
"@opentui/core": "0.5.13",
"@opentui/keymap": "0.5.13",
"@opentui/solid": "0.5.13",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+15 -3
View File
@@ -51,6 +51,17 @@ const RESPECTS_INLINE_HINTS = new Set([
"openrouter",
])
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
// breakpoint, so a conversation-tail breakpoint writes a new cache every step and costs more than none. Qwen ignores
// breakpoints on tool definitions and caches tools with the system prompt.
const openRouterPolicy = (modelID: string): CachePolicyObject => {
// `~anthropic/claude-sonnet-latest` style IDs are OpenRouter aliases for the latest model in a family.
const id = modelID.replace(/^~/, "")
if (id.startsWith("anthropic/")) return AUTO
if (id.startsWith("qwen/")) return { system: true, messages: { tail: 1 } }
return NONE
}
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -156,9 +167,10 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
return request
const policy = resolve(request.cache)
const policy =
request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")
? openRouterPolicy(request.model.id)
: resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
+10 -6
View File
@@ -426,6 +426,7 @@ interface ActiveContent {
readonly type: "text" | "reasoning"
readonly id: string
readonly thinking?: MistralThinkingContent
readonly thinkingUnits?: MistralThinkingUnit[]
}
export interface ParserState {
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
thinkingText(state.active.thinkingUnits ?? []),
)
return { ...state, lifecycle, active: undefined }
}
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
const units = thinkingUnits(part.thinking)
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
// Keep native units out of streamed events until the block is complete.
const accumulated = active.thinkingUnits ?? []
accumulated.push(...units)
const thinking = {
...active.thinking,
...part,
type: "thinking" as const,
thinking: [...(active.thinking?.thinking ?? []), ...units],
thinking: [],
}
const text = thinkingText(units)
return {
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
active: { ...active, thinking, thinkingUnits: accumulated },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
+47 -40
View File
@@ -20,7 +20,6 @@ import {
type LLMRequest,
type MediaPart,
type ReasoningPart,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
@@ -45,6 +44,7 @@ const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
@@ -121,12 +121,15 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
const OpenAIChatTextContent = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTextContent = Schema.Schema.Type<typeof OpenAIChatTextContent>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
OpenAIChatTextContent,
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -136,6 +139,7 @@ const OpenAIChatUserContent = Schema.Union([
file: Schema.Struct({ filename: Schema.String, file_data: Schema.String }),
}),
])
type OpenAIChatUserContent = Schema.Schema.Type<typeof OpenAIChatUserContent>
const OpenAIChatMessage = Schema.Union([
Schema.Struct({
@@ -149,21 +153,19 @@ const OpenAIChatMessage = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
content: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)])),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)]),
}),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -327,9 +329,7 @@ export interface ParserState {
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly cacheControl?: (cache: CacheHint | undefined) => OpenAIChatCacheControl | undefined
readonly toolCallID?: (id: string) => string
}
@@ -415,7 +415,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const content: OpenAIChatUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
@@ -441,14 +441,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
requireReasoning: boolean,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
const content: TextPart[] = []
const content: OpenAIChatTextContent[] = []
const reasoning: ReasoningPart[] = []
const toolCalls: OpenAIChatAssistantToolCall[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
content.push(part)
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
continue
}
if (part.type === "reasoning") {
@@ -486,14 +486,15 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = {
role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
content: (() => {
if (content.some((part) => part.cache_control !== undefined)) return content
if (content.length === 0 && toolCalls.length > 0) return null
return content.map((part) => part.text).join("")
})(),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(details !== undefined ? { reasoning_details: details } : {}),
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
@@ -504,33 +505,38 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = []
const attachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const attachments: OpenAIChatUserContent[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
messages.push(
toolMessage(
options.toolCallID?.(part.id) ?? part.id,
ProviderShared.toolResultText(part),
options.cacheControl?.(part.cache),
),
)
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
messages.push(
toolMessage(options.toolCallID?.(part.id) ?? part.id, text.join("\n"), options.cacheControl?.(part.cache)),
)
const files = content.filter((item) => item.type === "file")
attachments.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
}
return { messages, attachments }
})
// Chat cache breakpoints belong on text content parts, not on the message itself.
const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatCacheControl | undefined) => ({
role: "tool" as const,
tool_call_id: toolCallID,
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
@@ -589,7 +595,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingAttachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const pendingAttachments: OpenAIChatUserContent[] = []
const flushAttachments = () => {
if (pendingAttachments.length === 0) return
bridgeTools()
@@ -599,24 +605,25 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
const cacheControl = options.cacheControl?.(part.cache)
if (pendingAttachments.length > 0) {
messages.push({
role: "user",
content: [
...pendingAttachments.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
})
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = options.cacheControl?.(part.cache)
messages[messages.length - 1] = cacheControl
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
@@ -625,15 +632,15 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
role: "user",
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
}
else
messages.push(
options.cacheControl?.(part.cache)
cacheControl
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
content: [{ type: "text", text: part.text, cache_control: cacheControl }],
}
: { role: "user", content: part.text },
)
+2
View File
@@ -16,6 +16,8 @@ import {
const patterns = [
/prompt is too long/i,
/input is too long for requested model/i,
// Cloudflare Workers AI reports this as HTTP 413.
/exceeded this model context window limit/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
/input token count.*exceeds the maximum/i,
@@ -4,7 +4,7 @@
"model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
"name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z"
"recordedAt": "2026-09-29T16:19:02.835Z"
},
"interactions": [
{
@@ -15,14 +15,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Think through the arithmetic, then reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":1536,\"temperature\":0,\"usage\":{\"include\":true},\"reasoning\":{\"max_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"Ev8CCrIBCBIYAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhN3oedXByb2ZfMDExQ2VkSE1TN0tEcTM4Z2JkUkp0bXN3qAH2ye/VBhIMhjcPOnE2wLhs0P05GgwA3z9luwbhnSyRMSQiMDYeIIThirdkJb/n2s2TxUk5EOMrSc1xejAb0RN7BIl66P5KhtwXvcZTiyCkBS8+7Cp6MOinWi+I8U7DhWskSA0pHZqaAncbV7qCIHkoZ5aT8jiPWplAOnlciDo2tLkGsLnzXrnfhI11GTF0kcfyhbrt3tjEDSiPstBbT2u19LJ5IqwV2VKl0pWVdl4PTsDwC09ClbnJj1y0A3AM9gEh4B9mEo4BvT0XVEncux4YAQ==.eyJlbmRwb2ludF9zbHVnIjoiYW50aHJvcGljL2NsYXVkZS00LjYtc29ubmV0LTIwMjYwMjE3fGNsYXVkZS1vbi1hd3MifQ\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":73,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
+11
View File
@@ -355,6 +355,17 @@ describe("provider error rawBody classification", () => {
}
})
test("classifies Workers AI context window rejections as context overflow despite HTTP 413", () => {
const message =
"AiError: Ai: The estimated number of input and maximum output tokens (900047) exceeded this model context window limit (32000). (17851589-f2e6-405d-b67c-5550e52ee1c1)"
const reason = classifyProviderFailure({
message,
status: 413,
rawBody: JSON.stringify({ errors: [{ message, code: 5021 }], success: false, result: {}, messages: [] }),
})
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
})
test("classifies Google invalid API keys as authentication failures", () => {
const rawBody = JSON.stringify({
error: {
+19 -3
View File
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
fixedResponse(
sseEvents(
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
chunk({ content: [{ type: "text", text: "Answer" }] }),
chunk({}, "stop"),
),
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
expect(response.reasoning).toBe("Consider")
expect(response.text).toBe("Answer")
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
["Con", undefined],
["sider", undefined],
])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
mistral: {
thinking: {
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
},
},
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
{ type: "text", text: "Answer" },
],
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
),
),
)
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -89,7 +89,14 @@ for (const item of cases) {
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
{
role: "assistant",
content:
item.model.route.id === "openrouter"
? [{ type: "text", text: response.text, cache_control: { type: "ephemeral" } }]
: response.text,
reasoning: response.reasoning,
},
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
@@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
),
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } })
}),
)
it.effect("retains the token-sharing response.failed event for retry hooks", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: {
id: "resp_usage_limit",
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
},
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded")
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+164 -4
View File
@@ -32,6 +32,125 @@ describe("OpenRouter", () => {
}),
)
it.effect("places default cache breakpoints on tools, system boundaries, and the conversation tail", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Model details" },
{ type: "text", text: "Project instructions" },
],
tools: [
{ name: "read", description: "Read", inputSchema: { type: "object", properties: {} } },
{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } },
],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.map((tool) => tool.cache_control)).toEqual([undefined, { type: "ephemeral" }])
expect(prepared.body.messages).toMatchObject([
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral" } },
{ text: "Model details" },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
expect(prepared.body.messages[0]?.content).not.toContainEqual(
expect.objectContaining({ text: "Model details", cache_control: expect.anything() }),
)
}),
)
it.effect("places default cache breakpoints on OpenRouter latest-model aliases", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("~anthropic/claude-sonnet-latest"),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(prepared.body.messages).toMatchObject([
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
it.effect("skips the tool breakpoint for Qwen, which caches tools with the system prompt", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toBeUndefined()
expect(prepared.body.messages).toMatchObject([
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
it.effect("places the default Qwen conversation-tail breakpoint inside tool-result text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
messages: [
Message.user("Call the tool"),
Message.assistant([{ type: "tool-call", id: "call_1", name: "lookup", input: {} }]),
Message.tool({ id: "call_1", name: "lookup", result: "Done" }),
],
}),
)
expect(prepared.body.messages.at(-1)).toMatchObject({
role: "tool",
tool_call_id: "call_1",
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral" } }],
})
}),
)
it.effect("sends no default breakpoints to upstreams that cache without them", () =>
Effect.gen(function* () {
const openrouter = OpenRouter.configure({ apiKey: "test-key" })
const bodies = yield* Effect.forEach(["google/gemini-2.5-flash", "openai/gpt-5-mini"], (id) =>
compileRequest(
LLM.request({
model: openrouter.model(id),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
).pipe(Effect.map((prepared) => prepared.body)),
)
bodies.forEach((body) => {
expect(body.tools?.[0]?.cache_control).toBeUndefined()
expect(body.messages).toMatchObject([
{ role: "system", content: "Base agent" },
{ role: "user", content: "Hello" },
])
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -74,6 +193,8 @@ describe("OpenRouter", () => {
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant("Unmarked reply"),
Message.user("Call again"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
@@ -90,8 +211,17 @@ describe("OpenRouter", () => {
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
{ role: "assistant", content: "Unmarked reply" },
{ role: "user", content: "Call again" },
{
role: "assistant",
content: [{ type: "text", text: "Calling", cache_control: { type: "ephemeral" } }],
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
},
{
role: "tool",
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } }],
},
])
}),
)
@@ -118,7 +248,7 @@ describe("OpenRouter", () => {
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
it.effect("does not emit text cache markers on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -130,8 +260,38 @@ describe("OpenRouter", () => {
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
{ role: "assistant", content: "" },
])
expect(prepared.body.messages[1]).not.toHaveProperty("cache_control")
}),
)
it.effect("counts wrapped system-update markers once so all four default breakpoints survive", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("Start"), Message.system("Updated instructions")],
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(prepared.body.messages.at(-1)).toMatchObject({
role: "user",
content: [
{ type: "text", text: "Start" },
{
type: "text",
text: "<system-update>\nUpdated instructions\n</system-update>",
cache_control: { type: "ephemeral" },
},
],
})
}),
)
@@ -4,9 +4,9 @@ import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { BrowserPaneEvent, BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
import { SessionBrowserPane } from "../src/session/browser/pane"
import { SessionBrowserPane, type SessionBrowserComment } from "../src/session/browser/pane"
export function mountBrowserPane() {
const host = document.createElement("main")
@@ -30,9 +30,15 @@ export function mountBrowserPane() {
covered: false,
captures: 0,
holdCapture: false,
picker: {} as Record<string, boolean | undefined>,
highlights: [] as string[],
comments: [] as SessionBrowserComment[],
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const inspectors = new Set<(event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void>()
const emitInspect = (event: Extract<BrowserPaneEvent, { type: "inspect" }>) =>
inspectors.forEach((listener) => listener(event))
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -60,6 +66,12 @@ export function mountBrowserPane() {
}
return canvas.convertToBlob()
},
// The desktop confirms each picker change, as the page does once inspect mode is armed.
inspect: (tabID, enabled) => {
setStore("picker", tab.title, enabled)
emitInspect({ type: "inspect", tabID, active: enabled })
},
highlight: (_tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]),
close: () => undefined,
},
]),
@@ -97,7 +109,26 @@ export function mountBrowserPane() {
}
if (command.type === "stop") setStore("loading", false)
},
onInspect: (listener) => {
inspectors.add(listener)
return () => inspectors.delete(listener)
},
reveal: () => undefined,
}
const pick = () =>
emitInspect({
type: "inspect",
tabID: browser.active().id,
active: false,
element: {
ref: Browser.Ref.make("e7"),
selector: "main > button.primary",
label: "button.primary",
role: "button",
name: "Save changes",
rect: { x: 48, y: 40, width: 160, height: 36 },
},
})
return (
<>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
@@ -137,13 +168,30 @@ export function mountBrowserPane() {
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
<button onClick={pick}>Pick element</button>
</nav>
<p>Captures: {store.captures}</p>
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
<p>Highlights: {store.highlights.join(",")}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
<SessionBrowserPane
browser={browser}
visible={store.visible}
onComment={(comment) => setStore("comments", (items) => [...items, comment])}
/>
</Show>
</div>
<ul data-testid="fixture-comments">
<For each={store.comments}>
{(comment) => (
<li>
{comment.element.label} {comment.element.ref ? `@${comment.element.ref}` : "(no ref)"}:{" "}
{comment.comment}
</li>
)}
</For>
</ul>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
@@ -79,6 +79,81 @@ story("keeps a still of the page under floating content that covers it", async (
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("comments on a picked element over a still of the page", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(picker).toHaveAttribute("aria-pressed", "true")
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
await expect(picker).toHaveAttribute("aria-pressed", "false")
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await expect(editor).toBeFocused()
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
// The native page hides behind its still so the editor can float over it.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.locator("#browser-panel img")).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("comment.png") })
await editor.fill("Make this the primary colour")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments")).toHaveText("button.primary @e7: Make this the primary colour")
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("cancels the picker and a comment with Escape", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
await picker.click()
await expect(picker).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Escape")
await expect(picker).toHaveAttribute("aria-pressed", "false")
await expect(root.getByText("Picker: off", { exact: true })).toBeVisible()
await picker.click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await expect(editor).toBeFocused()
await editor.press("Escape")
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
await expect(root.getByTestId("fixture-comments")).toHaveText("")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await editor.fill("Still misaligned after the reload")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(editor).toHaveValue("Still misaligned after the reload")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments")).toHaveText(
"button.primary (no ref): Still misaligned after the reload",
)
})
story("keeps the comment editor and its actions inside the page", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
await root.getByRole("button", { name: "Pick element", exact: true }).click()
const editor = root.locator('[data-slot="browser-comment-editor"]')
// As if the user dragged the textarea's resize handle far past the page.
await editor.locator("textarea").evaluate((element) => {
element.style.height = "900px"
})
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
const box = await editor.boundingBox()
expect(surface && box && box.y + box.height <= surface.y + surface.height).toBe(true)
const submit = editor.getByRole("button", { name: "Comment", exact: true })
await submit.scrollIntoViewIfNeeded()
await expect(submit).toBeInViewport()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
@@ -186,11 +186,17 @@ for (const scenario of scenarios) {
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
// Grouped file headers stack below the stuck Used header.
const group = element
.closest('[data-component="collapsed-tool-group"]')
?.querySelector(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const sessionTop = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
return {
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
groupGap: group ? Math.abs(group.getBoundingClientRect().top - sessionTop) : 0,
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
clickable: trigger.contains(
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
@@ -198,7 +204,7 @@ for (const scenario of scenarios) {
}
}),
)
.toEqual({ gap: 0, titleGap: 0, clickable: true })
.toEqual({ gap: 0, groupGap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import {
assistantMessage,
session,
setupTimeline,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const target = "ses_0123456789abcdefghijklmnop"
const missing = "ses_abcdefghijklmnopqrstuvwxyz"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("opens a verified session from agent prose or inline code with the keyboard", async ({ page }) => {
await setupTimeline(page, {
sessions: [session(), session({ id: target, title: "Linked session" })],
messages: [
userMessage(),
assistantMessage([textPart("prt_session_links", `Visit ${target} or \`${target}\` to see the result.`)]),
],
})
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `Visit ${target}` })
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.getByRole("button", { name: target })).toHaveCount(2)
await markdown
.getByRole("button", { name: target })
.filter({ has: page.locator("code") })
.press("Enter")
await expect(page).toHaveURL(`/server/${base64Encode(server)}/session/${target}`)
await expect(page.locator(`[data-titlebar-tab-link][href$="/session/${target}"]`)).toContainText("Linked session")
})
test("does not navigate to an ID that is absent from the current server", async ({ page }) => {
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_session_missing", `See ${missing}.`)])],
})
const markdown = page.locator('[data-component="markdown"]').filter({ hasText: `See ${missing}.` })
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await markdown.getByRole("button", { name: missing }).click()
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page).toHaveURL(/\/session\/ses_timeline_stability$/)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.19",
"version": "2.0.20",
"description": "",
"type": "module",
"exports": {
+4
View File
@@ -30,6 +30,10 @@ export type ComposerControls = {
opened: () => boolean
open: () => void
}
/** Present in sessions with a desktop browser pane. */
browser?: {
reveal: (tabID: string, ref?: string) => void
}
}
}
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test"
import { commentContextItem, formatBrowserCommentNote, readPromptPresentation } from "./comment-note"
const browser = {
type: "browser" as const,
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/",
element: { ref: "e42", selector: "#save", label: "button#save" },
comment: "Rename this",
}
describe("browser element comments", () => {
test("read from message metadata beside file comments and skip malformed entries", () => {
const value = readPromptPresentation({
displayText: "hi",
comments: [browser, { ...browser, element: { label: "button" } }, { path: "src/app.ts", comment: "Keep" }],
})
expect(value?.comments).toEqual([browser, { path: "src/app.ts", comment: "Keep" }])
})
test("explain a selector that crosses into a shadow root", () => {
expect(
formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "#card >>> div > button" } }),
).toContain('selector "#card >>> div > button" (">>>" enters a shadow root)')
expect(formatBrowserCommentNote(browser)).not.toContain("shadow root")
})
test("leave out a selector that was too long to keep", () => {
expect(formatBrowserCommentNote({ ...browser, element: { ...browser.element, selector: "" } })).toBe(
'The user made the following comment regarding the "button#save" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/ (browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Rename this',
)
})
test("return to the composer without their element ref", () => {
expect(commentContextItem(browser)).toEqual({
...browser,
element: { selector: "#save", label: "button#save" },
commentID: expect.any(String),
})
})
})
+42 -2
View File
@@ -1,12 +1,18 @@
import { Option, Schema } from "effect"
import type { FileSelection } from "@/workspaces/files/model"
import { BrowserComment, durableBrowserElement, type ContextItem } from "./schema"
export type PromptComment = {
export type PromptFileComment = {
type?: "file"
path: string
selection?: FileSelection
comment: string
preview?: string
origin?: "review" | "file"
}
export type PromptComment = PromptFileComment | BrowserComment
const decodeBrowserComment = Schema.decodeUnknownOption(BrowserComment)
/** An attachment the model receives as a path on the server rather than inline bytes. */
export type PromptAttachmentReference = {
@@ -30,7 +36,7 @@ function selection(selection: unknown) {
} satisfies FileSelection
}
export function createCommentMetadata(input: PromptComment) {
export function createCommentMetadata(input: PromptFileComment) {
return {
opencodeComment: {
path: input.path,
@@ -78,6 +84,7 @@ export function readPromptPresentation(value: unknown) {
}),
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeBrowserComment(item))
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
@@ -100,6 +107,39 @@ export function formatAttachmentReference(input: PromptAttachmentReference) {
return `Attached file: \`${input.path}\``
}
export function formatBrowserCommentNote(input: BrowserComment) {
const element = input.element
// Page-provided strings are quoted so they read as data, not as part of the user's request.
const details = [
element.role ? `role ${element.role}` : undefined,
element.name ? `accessible name ${JSON.stringify(element.name)}` : undefined,
element.text && element.text !== element.name ? `text ${JSON.stringify(element.text.slice(0, 80))}` : undefined,
// A selector too long to keep is empty rather than cut into invalid syntax.
element.selector
? `selector ${JSON.stringify(element.selector)}${element.selector.includes(" >>> ") ? ' (">>>" enters a shadow root)' : ""}`
: undefined,
element.ref
? `browser ref @${element.ref}, usable as ref in any browser tool including browser.evaluate until the page navigates`
: undefined,
].filter((detail) => detail !== undefined)
return `The user made the following comment regarding the ${JSON.stringify(element.label)} element in browser tab ${input.tabID} at ${input.url}${details.length ? ` (${details.join("; ")})` : ""}: ${input.comment}`
}
/** Restores a sent comment to the composer, for example after a revert or fork. */
export function commentContextItem(comment: PromptComment): ContextItem {
// The message may predate the desktop process, so its element ref can no longer be trusted.
if (comment.type === "browser")
return { ...comment, element: durableBrowserElement(comment.element), commentID: crypto.randomUUID() }
return {
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
}
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
@@ -279,6 +279,39 @@ export const MixedAttachments = {
),
}
export const BrowserElementComment = {
render: () => (
<ComposerStory
prompt={text("Tidy up the settings page")}
comments={[
{
type: "browser",
key: "browser:tab_story:c=save",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > form > button.btn.primary",
label: "button.btn.primary",
role: "button",
name: "Save changes",
},
comment: "Match the secondary button height",
commentID: "save",
},
{
type: "file",
key: "comment:src/app.tsx",
path: "src/app.tsx",
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
comment: "Keep the normal flow flat",
},
]}
/>
),
}
export const ModelAndVariant = { render: () => <ComposerStory prompt={text("Compare both variants")} /> }
export const SlashSuggestions = { render: () => <ComposerStory suggestions="command" /> }
+5 -2
View File
@@ -533,8 +533,11 @@ export function ComposerAttachments(props: {
>
<CommentCard
comment={comment.comment ?? ""}
path={comment.path}
selection={comment.selection}
target={
comment.type === "browser"
? { type: "browser", element: comment.element.label }
: { type: "file", path: comment.path, selection: comment.selection }
}
active={comment.key === props.activeCommentID}
onClick={() => props.onCommentClick?.(comment)}
/>
+12 -6
View File
@@ -98,7 +98,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
const comment = item.comment?.trim()
if (!comment) return []
if (!comment || item.type !== "file") return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
@@ -129,9 +129,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file",
// History records file comments only; browser comments stay with the draft while it is browsed.
prompt.context.replaceComments([
...prompt.context.items().filter((item) => item.type === "browser"),
...items.map((item) => ({
type: "file" as const,
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
@@ -139,7 +141,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
commentOrigin: item.origin,
preview: item.preview,
})),
)
])
}
const referenceDescription = (reference: ReferenceInfo) =>
@@ -298,7 +300,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
})),
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
if (item.type === "file" && item.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
@@ -308,6 +310,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
openContext(key) {
const item = controller.contextItem(key)
if (item?.type === "browser") {
adapter.controls().session.browser?.reveal(item.tabID, item.element.ref)
return
}
if (item) openComment(item, adapter.controls(), layout, files, comments)
},
onEditor(element) {
+2 -1
View File
@@ -28,6 +28,7 @@ export {
} from "./state"
export type {
AgentPart,
BrowserContextItem,
ContentPart,
ContextItem,
FileAttachmentPart,
@@ -160,7 +161,7 @@ export const { use: useComposerState, provider: ComposerPersistenceProvider } =
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
replaceComments: (items: ContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
+51
View File
@@ -144,6 +144,57 @@ describe("buildPromptRequest", () => {
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("sends a browser element comment as a note the browser tools can act on", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "tidy up", start: 0, end: 7 }],
context: [
{
key: "browser:tab:c=1",
type: "browser",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > button.primary",
label: "button.primary",
role: "button",
name: "Save",
text: "Save",
},
comment: "Match @src/button.css",
commentID: "1",
},
],
images: [],
text: "tidy up",
sessionDirectory: "/repo",
})
expect(result.text).toBe(
'tidy up\nThe user made the following comment regarding the "button.primary" element in browser tab tab_00000000-0000-4000-8000-000000000000 at http://localhost:5173/settings (role button; accessible name "Save"; selector "#settings > button.primary"; browser ref @e42, usable as ref in any browser tool including browser.evaluate until the page navigates): Match @src/button.css',
)
expect(result.comments).toEqual([
{
type: "browser",
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/settings",
title: "Settings",
element: {
ref: "e42",
selector: "#settings > button.primary",
label: "button.primary",
role: "button",
name: "Save",
text: "Save",
},
comment: "Match @src/button.css",
},
])
// A browser comment has no file of its own; only files it mentions are attached.
expect(result.files).toEqual([{ uri: "file:///repo/src/button.css", mime: "text/plain", name: "button.css" }])
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
+35 -21
View File
@@ -1,9 +1,18 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type {
AgentPart,
ContextItem,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "@/composer/state"
import {
formatAttachmentReference,
formatBrowserCommentNote,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
@@ -20,20 +29,9 @@ type PromptRequest = {
attachments: PromptAttachmentReference[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
context: (ContextItem & { key: string })[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
@@ -87,7 +85,27 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const mentioned = (comment: string) =>
parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
const context = input.context.flatMap((item) => {
if (item.type === "browser") {
const comment = item.comment.trim()
if (!comment) return []
comments.push({
type: "browser",
tabID: item.tabID,
url: item.url,
...(item.title ? { title: item.title } : {}),
element: { ...item.element },
comment,
})
return mentioned(comment)
}
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
@@ -104,13 +122,7 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
return [file, ...mentioned(comment)]
})
const inline = input.images.map((attachment) => ({
@@ -127,7 +139,9 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
text: [
...(input.text.trim() ? [input.text] : []),
...attachments.map(formatAttachmentReference),
...comments.map(formatCommentNote),
...comments.map((comment) =>
comment.type === "browser" ? formatBrowserCommentNote(comment) : formatCommentNote(comment),
),
].join("\n"),
displayText: input.text,
files: [...files, ...context, ...inline],
+22
View File
@@ -53,6 +53,28 @@ describe("composer persistence schemas", () => {
expect(decode({}).prompt).toEqual(DEFAULT_PROMPT)
})
test("restores browser element comments without the process-bound element ref", () => {
const decode = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)
const browser = {
type: "browser" as const,
tabID: "tab_00000000-0000-4000-8000-000000000000",
url: "http://localhost:5173/",
element: { ref: "e42", selector: "#save", label: "button#save", role: 42 },
comment: "Rename this",
commentID: "note",
}
const value = decode({ context: { items: [browser, { ...browser, url: null }] } })
expect(value.context.items).toEqual([
{
...browser,
element: { selector: "#save", label: "button#save" },
key: "browser:tab_00000000-0000-4000-8000-000000000000:c=note",
},
])
})
test("drops invalid parts without losing valid mentions or optional field recovery", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
+57 -2
View File
@@ -134,9 +134,49 @@ export const FileContextItem = Persistence.struct({
preview: Persistence.optional(Schema.String),
})
export type FileContextItem = typeof FileContextItem.Type
export type ContextItem = FileContextItem
/** An element the user picked in a desktop browser tab. The ref is valid until the page navigates. */
export const BrowserElement = Persistence.struct({
ref: Persistence.optional(Schema.String),
selector: Schema.String,
label: Schema.String,
role: Persistence.optional(Schema.String),
name: Persistence.optional(Schema.String),
text: Persistence.optional(Schema.String),
})
export type BrowserElement = typeof BrowserElement.Type
/**
* A ref names an element only inside the desktop process that picked it; a later process can hand
* the same ref to another element. Anything that may outlive the pick keeps the description only.
*/
export function durableBrowserElement(element: BrowserElement): BrowserElement {
return {
selector: element.selector,
label: element.label,
...(element.role ? { role: element.role } : {}),
...(element.name ? { name: element.name } : {}),
...(element.text ? { text: element.text } : {}),
}
}
const BrowserCommentFields = {
type: Schema.Literal("browser"),
tabID: Schema.String,
url: Schema.String,
title: Persistence.optional(Schema.String),
element: BrowserElement,
comment: Schema.String,
}
/** A comment on a browser element as sent in message metadata. */
export const BrowserComment = Persistence.struct(BrowserCommentFields)
export type BrowserComment = typeof BrowserComment.Type
export const BrowserContextItem = Persistence.struct({ ...BrowserCommentFields, commentID: Schema.String })
export type BrowserContextItem = typeof BrowserContextItem.Type
export type ContextItem = FileContextItem | BrowserContextItem
export function contextItemKey(item: ContextItem) {
if (item.type === "browser") return `browser:${item.tabID}:c=${item.commentID}`
const key = `${item.type}:${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
if (item.commentID) return `${key}:c=${item.commentID}`
const comment = item.comment?.trim()
@@ -145,12 +185,27 @@ export function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
const ContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
const FileContextEntry = Schema.Struct({ ...FileContextItem.fields, key: Persistence.optional(Schema.String) }).pipe(
Schema.decodeTo(Persistence.struct({ ...FileContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
decode: SchemaGetter.transform((item) => ({ ...item, key: contextItemKey(item) })),
encode: SchemaGetter.transform((item) => item),
}),
)
const BrowserContextEntry = Schema.Struct({
...BrowserContextItem.fields,
key: Persistence.optional(Schema.String),
}).pipe(
Schema.decodeTo(Persistence.struct({ ...BrowserContextItem.fields, key: Schema.String }).pipe(Schema.toType), {
// A stored draft can outlive the desktop process that picked the element.
decode: SchemaGetter.transform((item) => ({
...item,
element: durableBrowserElement(item.element),
key: contextItemKey(item),
})),
encode: SchemaGetter.transform((item) => item),
}),
)
const ContextEntry = Schema.Union([FileContextEntry, BrowserContextEntry])
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+6 -1
View File
@@ -12,7 +12,11 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
export function createComposerControls(input: {
sessionKey: Accessor<string>
model?: ModelSelection
browser?: ComposerControls["session"]["browser"]
}) {
const layout = useLayout()
const local = useLocal()
const sdk = useWorkspaceLocation()
@@ -40,6 +44,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
session: {
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
browser: input.browser,
},
}
})
+5 -2
View File
@@ -17,6 +17,9 @@ import {
export { DEFAULT_PROMPT } from "./schema"
export type {
AgentPart,
BrowserComment,
BrowserContextItem,
BrowserElement,
ComposerStore,
ContentPart,
ContextItem,
@@ -38,7 +41,7 @@ type InitialPrompt = {
}
export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
return !!item.comment?.trim()
}
function createComposerActions(setStore: SetStoreFunction<ComposerStore>) {
@@ -137,7 +140,7 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
)
clearRetry()
},
replaceComments(items: FileContextItem[]) {
replaceComments(items: ContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+53 -26
View File
@@ -1,6 +1,7 @@
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionMessageUser } from "@opencode/client/promise"
import type { Accessor } from "solid-js"
import type { PromptComment } from "./comment-note"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
@@ -73,10 +74,13 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
})),
context: input.adapter.state.context
.items()
.map((item) =>
item.type === "browser"
? { ...item, element: { ...item.element } }
: { ...item, selection: item.selection ? { ...item.selection } : undefined },
),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
if (!read) {
@@ -195,19 +199,30 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
{
path: item.path,
comment: item.comment.trim(),
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
: [],
),
comments: value.context.flatMap((item): PromptComment[] => {
const comment = item.comment?.trim()
if (!comment) return []
if (item.type === "browser")
return [
{
type: "browser",
tabID: item.tabID,
url: item.url,
...(item.title ? { title: item.title } : {}),
element: { ...item.element },
comment,
},
]
return [
{
path: item.path,
comment,
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
}),
agent: value.selection.agent,
model: {
...value.selection.model,
@@ -287,15 +302,27 @@ function restoreSubmission(
restored.target.context.replaceComments(
restored.context
.filter((item) => !!item.comment?.trim())
.map((item) => ({
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})),
.map((item) =>
item.type === "browser"
? {
type: "browser",
tabID: item.tabID,
url: item.url,
title: item.title,
element: item.element,
comment: item.comment,
commentID: item.commentID,
}
: {
type: "file",
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
},
),
)
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
+1
View File
@@ -5,6 +5,7 @@ export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./run
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneElement,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
+36
View File
@@ -445,6 +445,42 @@
}
}
@keyframes browser-comment-enter {
from {
opacity: 0;
}
}
@keyframes browser-comment-editor-enter {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
}
[data-component="browser-comment"] {
animation: browser-comment-enter 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
}
/* Dim the frozen page around the picked element, whose DevTools highlight stays in the still. */
[data-component="browser-comment"] [data-slot="browser-comment-spotlight"] {
box-shadow:
0 0 0 1px var(--v2-border-border-focus),
0 0 0 100vmax color-mix(in srgb, var(--v2-overlay-simple-overlay-scrim) 50%, transparent);
}
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
animation: browser-comment-editor-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
transform-origin: top left;
}
@media (prefers-reduced-motion: reduce) {
[data-component="browser-comment"],
[data-component="browser-comment"] [data-slot="browser-comment-editor"] {
animation: none;
}
}
[data-slot="home-projects-scroll"] {
timeline-scope: --home-projects-scroll;
}
@@ -62,6 +62,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
return createMemo<PromptProjectControls>(() => ({
available: projects(),
directory: location().directory,
projectID: location().current?.project.id,
server: servers.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
select: selectProject,
add: addProject,
@@ -0,0 +1,51 @@
import { describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import type { PromptProjectControls } from "./selector"
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
const { createPromptProjectController } = await import("./selector")
describe("new session project selection", () => {
test("shows the current project's appearance before its worktree inventory loads", () => {
const generic = { id: "other", worktree: "/other" }
const current = {
id: "current",
worktree: "/repo",
name: "My custom name",
icon: { override: "data:image/png;base64,AAAA" },
}
const controls: PromptProjectControls = {
available: [generic, current],
directory: "/repo/.opencode/worktree/feature",
projectID: "current",
select() {},
add() {},
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
expect(controller.selected()).toEqual(current)
dispose()
})
})
test("does not select a matching project ID from another server", () => {
const remote = { id: "shared", worktree: "/remote", server: { key: "remote", name: "Remote" } }
const local = { id: "shared", worktree: "/local", server: { key: "local", name: "Local" } }
const controls: PromptProjectControls = {
available: [remote, local],
directory: "/local/feature",
projectID: "shared",
server: "local",
select() {},
add() {},
}
createRoot((dispose) => {
const controller = createPromptProjectController({ controls: () => controls, onDone() {} })
expect(controller.selected()).toEqual(local)
dispose()
})
})
})
@@ -31,6 +31,7 @@ export type PromptProject = {
export type PromptProjectControls = {
available: PromptProject[]
directory: string
projectID?: string
server?: string
select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
@@ -56,14 +57,15 @@ export function createPromptProjectController(input: {
let searchRef: HTMLInputElement | undefined
const current = () => {
const key = pathKey(input.controls().directory)
return input
.controls()
.available.find(
const controls = input.controls()
const key = pathKey(controls.directory)
const projects = controls.available.filter((project) => !project.server || project.server.key === controls.server)
return (
projects.find(
(project) =>
(!project.server || project.server.key === input.controls().server) &&
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
)
pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
) ?? projects.find((project) => controls.projectID && project.id === controls.projectID)
)
}
const selected = () => current() ?? input.controls().available[0]
const projects = () => {
@@ -0,0 +1,23 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { onMount } from "solid-js"
import { DialogChatGPTUsageLimit } from "./chatgpt-usage-limit"
function UsageLimitStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogChatGPTUsageLimit />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open ChatGPT usage limit
</Button>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-chatgpt-usage-limit",
}
export const ChatGPTUsageLimit = { render: () => <UsageLimitStory /> }
@@ -0,0 +1,48 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
export function DialogChatGPTUsageLimit() {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
<DialogTitle>
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.title")}</bdi>
</DialogTitle>
</div>
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.description")}</bdi>
</p>
<Button
variant="contrast"
size="large"
class="mt-8 w-full"
autofocus
onClick={() => {
platform.openExternal("https://chatgpt.com/settings/usage")
dialog.close()
}}
>
{language.t("provider.connect.chatgptUsageLimit.manage")}
</Button>
<Button
variant="ghost"
size="large"
class="mt-2 w-full"
onClick={() => dialog.close()}
>
{language.t("provider.connect.chatgptUsageLimit.close")}
</Button>
</div>
</Dialog>
)
}
@@ -0,0 +1,23 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { onMount } from "solid-js"
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
function WelcomeStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogChatGPTPlanWelcome />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open ChatGPT plan welcome
</Button>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-chatgpt-welcome",
}
export const ChatGPTPlanWelcome = { render: () => <WelcomeStory /> }
@@ -0,0 +1,37 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
export function DialogChatGPTPlanWelcome() {
const dialog = useDialog()
const language = useLanguage()
return (
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
<DialogTitle>
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.title")}</bdi>
</DialogTitle>
</div>
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.description")}</bdi>
</p>
<ExternalLink
href="https://chatgpt.com/settings/usage"
dir="auto"
class="mt-1 rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("provider.connect.chatgptWelcome.usage")}
</ExternalLink>
<Button variant="contrast" size="large" class="mt-8 w-full" autofocus onClick={() => dialog.close()}>
{language.t("provider.connect.chatgptWelcome.confirm")}
</Button>
</div>
</Dialog>
)
}
+29 -6
View File
@@ -39,6 +39,9 @@ import { OpenCodeLogo } from "@/providers/opencode-logo"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { useTabs } from "@/shell/tabs/tabs"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema } from "effect"
import {
CONSOLE_INTEGRATION,
CONSOLE_PROVIDERS,
@@ -48,6 +51,7 @@ import {
type ProviderConnectMethod,
} from "./controller"
import { ConsoleAuthorization } from "./console"
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
import { authServerName, RemoteAuthNotice } from "./remote"
import "./models.css"
@@ -81,8 +85,15 @@ export const DialogConnectProvider: Component<{
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
chatgptWelcome: false,
})
const language = useLanguage()
const dialog = useDialog()
const [welcome, setWelcome, , welcomeReady] = persisted(
Persist.global("chatgpt-plan-welcome.v1"),
Persistence.struct({ seen: Schema.Boolean }),
{ seen: false },
)
const reset = controller.reset
const back = { current: reset }
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
@@ -112,7 +123,11 @@ export const DialogConnectProvider: Component<{
setBack={(handler) => (back.current = handler)}
selection={props.selection}
onDone={props.onDone ? () => setState("completed", true) : undefined}
onConnected={() => props.onConnected?.(provider)}
onConnected={(methodID) => {
props.onConnected?.(provider)
if (provider === "openai" && methodID === "chatgpt-token-sharing")
setState("chatgptWelcome", true)
}}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
/>
@@ -136,9 +151,16 @@ export const DialogConnectProvider: Component<{
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
}
onCloseAutoFocus={(event) => {
if (!state.completed || !props.onDone) return
event.preventDefault()
props.onDone()
if (state.completed && props.onDone) {
event.preventDefault()
props.onDone()
}
if (!state.chatgptWelcome) return
void Promise.resolve(welcomeReady.promise).then(() => {
if (welcome.seen) return
setWelcome("seen", true)
void dialog.show(() => <DialogChatGPTPlanWelcome />)
})
}}
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
classList={{
@@ -354,7 +376,7 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void
selection?: ModelSelection
onDone?: () => void
onConnected?: () => void
onConnected?: (methodID?: string) => void
onFirstConnection: (provider: { id: string; name: string }) => void
onAuthorization: (authorization: boolean) => void
}) {
@@ -401,7 +423,8 @@ function ProviderConnection(props: {
prepare: isConsole ? prepareConsoleCatalog : undefined,
pollInterval: isConsole ? 500 : undefined,
onComplete: () => {
props.onConnected?.()
const method = controller.currentMethod()
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
global.models.show(
@@ -0,0 +1,78 @@
import { Button } from "@opencode/ui/button"
import { createSignal } from "solid-js"
import type { ModelSelection } from "./selection"
import { ModelSelectorPopoverView } from "./select-dialog"
const chatgpt = {
id: "gpt-5.6-sol",
providerID: "openai",
api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" },
name: "GPT-5.6 Sol",
family: "gpt",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: true,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-09-01",
variants: {},
provider: {
id: "openai",
name: "OpenAI",
source: "custom",
env: [],
options: {},
models: {},
},
latest: true,
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
const models = [
chatgpt,
{
...chatgpt,
id: "gpt-5.6-terra",
api: { ...chatgpt.api, id: "gpt-5.6-terra" },
name: "GPT-5.6 Terra",
latest: false,
},
]
function SelectorStory(props: { plan: boolean }) {
const [current, setCurrent] = createSignal(models[0].id)
return (
<div class="flex min-h-[280px] items-end justify-center">
<ModelSelectorPopoverView
trigger={(trigger) => (
<Button {...trigger} variant="ghost-muted">
{models.find((model) => model.id === current())?.name}
</Button>
)}
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
groups={(items) => [{ category: "openai", items }]}
current={`openai:${current()}`}
chatgptPlan={props.plan}
select={(item) => setCurrent(item.id)}
onManage={() => undefined}
onClose={() => undefined}
/>
</div>
)
}
export default {
title: "App/Dialogs/Model Selector",
id: "app-dialog-model-selector",
}
export const ChatGPTPlan = { render: () => <SelectorStory plan /> }
export const ApiKey = { render: () => <SelectorStory plan={false} /> }
@@ -15,13 +15,16 @@ import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { decode64 } from "@/runtime/persistence/base64"
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -218,11 +221,21 @@ export function ModelSelectorPopover(props: {
onClose?: () => void
}) {
const dialog = useDialog()
const data = useData()
const location = useWorkspaceLocation()
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
const chatgptPlan = () => {
if (!controller.current()?.startsWith("openai:")) return false
const connection = data.location.integration
.list(location().ref)
?.find((integration) => integration.id === "openai")
?.connections[0]
return connection?.type === "credential" && connection.method === "oauth"
}
return (
<ModelSelectorPopoverView
@@ -230,6 +243,7 @@ export function ModelSelectorPopover(props: {
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
@@ -281,11 +295,12 @@ function createModelSelectorController(input: {
}
}
function ModelSelectorPopoverView(props: {
export function ModelSelectorPopoverView(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined
chatgptPlan?: boolean
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
@@ -368,7 +383,8 @@ function ModelSelectorPopoverView(props: {
<Menu.Portal>
<Menu.Content
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
classList={{ "!w-[320px]": props.chatgptPlan }}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
@@ -505,6 +521,20 @@ function ModelSelectorPopoverView(props: {
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</Menu.Item>
</div>
<Show when={props.chatgptPlan}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex min-h-10 items-center gap-2 px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
<ExternalLink
href="https://chatgpt.com/settings/usage"
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("dialog.model.chatgptManageUsage")}
<Icon name="arrow-up-right" size="small" />
</ExternalLink>
</div>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
+14
View File
@@ -111,6 +111,7 @@ export const dict = {
"command.file.open": "Open file",
"command.browser.open": "Open browser",
"command.browser.reload": "Reload browser page",
"command.browser.inspect": "Select an element in the browser page",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
"command.context.addSelection": "Add selection to context",
@@ -186,6 +187,8 @@ export const dict = {
"dialog.model.empty": "No model results",
"dialog.model.manage": "Manage models",
"dialog.model.manage.description": "Customize which models appear in the model selector.",
"dialog.model.chatgptPlan": "Using ChatGPT plan",
"dialog.model.chatgptManageUsage": "Manage usage",
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
@@ -217,7 +220,15 @@ export const dict = {
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.available": "Available models",
"provider.connect.chatgptWelcome.title": "ChatGPT connected",
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode can use your ChatGPT plan.",
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
"provider.connect.chatgptWelcome.confirm": "Got it",
"provider.connect.models.list": "Models available from {{provider}}",
"provider.connect.chatgptUsageLimit.title": "ChatGPT usage limit reached",
"provider.connect.chatgptUsageLimit.description": "Check your usage and limits in ChatGPT settings.",
"provider.connect.chatgptUsageLimit.manage": "Manage usage",
"provider.connect.chatgptUsageLimit.close": "Close",
"provider.connect.console.refreshFailed":
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
"provider.connect.console.connected": "OpenCode connected",
@@ -1012,6 +1023,9 @@ export const dict = {
"session.browser.unsupported": "This desktop app does not support the browser pane.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.inspect": "Select an element to comment on",
"session.browser.inspect.active": "Click an element in the page to comment on it. Press Escape to cancel.",
"session.browser.inspect.pageShortcut": "While the page has focus",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
@@ -17,16 +17,32 @@ export type BrowserPaneLayout = {
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
/** An element the user picked in the page. The ref stays valid for browser tools until the page navigates. */
export type BrowserPaneElement = {
ref: Browser.Ref
selector: string
label: string
role?: string
name?: string
text?: string
/** Border box in the native view's DIPs. */
rect: { x: number; y: number; width: number; height: number }
}
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
| { type: "inspect"; tabID: Browser.TabID; active: boolean; element?: BrowserPaneElement }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
/** Starts or stops the page's element picker; the page reports picks and exits as inspect events. */
inspect(tabID: Browser.TabID, enabled: boolean): void
/** Highlights a picked element briefly, or clears any highlight when ref is omitted. */
highlight(tabID: Browser.TabID, ref?: Browser.Ref): void
close(): void
}
@@ -2,7 +2,8 @@ import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, r
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
@@ -13,6 +14,7 @@ import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
type Server = ReturnType<typeof useServer>
type InspectEvent = Extract<BrowserPaneEvent, { type: "inspect" }>
export type BrowserAttachment = BrowserConnectionState
@@ -42,6 +44,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const preview = new Map<string, Set<(path: string) => void>>()
const inspect = new Map<string, Set<(event: InspectEvent) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(() => !!platform.browserPane)
const close = (id: string) => {
@@ -110,6 +113,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
inspect: (event) => inspect.get(id)?.forEach((listener) => listener(event)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -152,20 +156,34 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
},
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
const id = key(server, sessionID)
const listeners = preview.get(id) ?? new Set()
listeners.add(listener)
preview.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) preview.delete(id)
}
return subscribe(preview, key(server, sessionID), listener)
},
/** The page's element picker starting, stopping, or picking an element. */
onInspect(server: Server, sessionID: string, listener: (event: InspectEvent) => void) {
return subscribe(inspect, key(server, sessionID), listener)
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
const connection = live.get(key(server, sessionID))?.connection
if (!connection) return Promise.reject(new Error("browser.pane.unavailable"))
return connection.command(command)
},
highlight(server: Server, sessionID: string, tabID: Browser.TabID, ref?: Browser.Ref) {
live.get(key(server, sessionID))?.connection.highlight(tabID, ref)
},
}
},
})
function subscribe<Value>(
listeners: Map<string, Set<(value: Value) => void>>,
id: string,
listener: (value: Value) => void,
) {
const set = listeners.get(id) ?? new Set()
set.add(listener)
listeners.set(id, set)
return () => {
set.delete(listener)
if (!set.size) listeners.delete(id)
}
}
@@ -29,11 +29,14 @@ function fixture() {
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const inspections: Extract<BrowserPaneEvent, { type: "inspect" }>[] = []
const highlights: (Browser.Ref | undefined)[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
inspect: (event) => inspections.push(event),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -46,6 +49,10 @@ function fixture() {
async capture() {
return null
},
inspect() {},
highlight(_tabID, ref) {
highlights.push(ref)
},
close() {
call.closed = true
},
@@ -55,9 +62,30 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint, previews }
return { connection, calls, states, endpoint, previews, inspections, highlights }
}
test("picked elements reach the session and highlights reach the page without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
const element = {
ref: Browser.Ref.make("e4"),
selector: "#save",
label: "button#save",
rect: { x: 1, y: 2, width: 3, height: 4 },
}
app.calls[0].emit({ type: "inspect", tabID, active: false, element })
expect(app.inspections).toEqual([{ type: "inspect", tabID, active: false, element }])
app.connection.highlight(tabID, element.ref)
app.connection.highlight(tabID)
expect(app.highlights).toEqual([element.ref, undefined])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
@@ -1,4 +1,5 @@
import type {
BrowserPaneEvent,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
@@ -20,6 +21,7 @@ export function createBrowserConnection(input: {
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
inspect: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -35,6 +37,7 @@ export function createBrowserConnection(input: {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.type === "inspect") return input.inspect(event)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
@@ -72,6 +75,9 @@ export function createBrowserConnection(input: {
register()
return state.registration?.command(command) ?? Promise.reject(new Error("browser.pane.unavailable"))
},
highlight(tabID: Browser.TabID, ref?: Browser.Ref) {
state.registration?.highlight(tabID, ref)
},
dispose() {
disposed = true
clearTimeout(retry)
+20 -1
View File
@@ -2,7 +2,7 @@ import { batch, createEffect, createMemo, on } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import type { BrowserPaneCommand, BrowserPaneEvent } from "@/runtime/platform/browser-pane"
import { useServer } from "@/runtime/server/current"
import { useCommand } from "@/shell/commands/command"
import type { SessionModel } from "../model"
@@ -120,5 +120,24 @@ export function createSessionBrowser(session: SessionModel) {
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
open,
command,
onInspect(listener: (event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void) {
const sessionID = session.identity.sessionID()
return sessionID ? attachments.onInspect(server, sessionID, listener) : () => undefined
},
/** Shows the browser tab that owns a picked element and flashes the element. */
reveal(tabID: string, ref?: string) {
const sessionID = session.identity.sessionID()
const tab = browserTabs().find((tab) => tab.id === tabID)
if (!sessionID || !tab) return
const view = session.layout.view()
if (!view.reviewPanel.opened()) view.reviewPanel.open()
session.layout.tabs().setActive(sessionBrowserTab(tab.id))
if (ref && isRef(ref)) attachments.highlight(server, sessionID, tab.id, ref)
},
}
}
// Stored comments keep refs as plain strings; this matches the desktop's ref format.
function isRef(value: string): value is Browser.Ref {
return /^e[1-9][0-9]*$/.test(value)
}
+263 -5
View File
@@ -1,20 +1,36 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { LineCommentEditor, type LineCommentEditorMention } from "@opencode/ui/line-comment"
import { Loader } from "@opencode/ui/loader"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createEffect, For, on, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneElement } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import { formatKeybindParts, useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
/** A comment on an element the user picked in the page. The ref is absent once the page navigated. */
export type SessionBrowserComment = {
tabID: Browser.TabID
url: string
title: string
element: Omit<BrowserPaneElement, "rect" | "ref"> & { ref?: Browser.Ref }
comment: string
}
export function SessionBrowserPane(props: {
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
onComment?: (comment: SessionBrowserComment) => void
mention?: LineCommentEditorMention
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
@@ -33,8 +49,91 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
// The tab whose element picker is on.
picking: undefined as Browser.TabID | undefined,
// A picked element awaiting its comment. The page stays frozen as a still until it closes.
comment: undefined as
| {
tabID: Browser.TabID
url: string
title: string
// The tab's navigation count at the pick; the element's ref dies when it changes.
generation: number
element: BrowserPaneElement
draft: string
}
| undefined,
size: { width: 0, height: 0 },
editorHeight: 0,
})
const empty = () => !address() && !state()?.loading && !store.navigating
const inspectable = () => !!props.onComment && !!address() && !failed() && !props.browser.suspended()
const picking = () => !!state() && store.picking === state()?.id
const setPicking = (tabID: Browser.TabID, enabled: boolean) => {
registration()?.inspect(tabID, enabled)
setStore("picking", enabled ? tabID : undefined)
}
const toggleInspect = () => {
const tab = state()
if (!tab || !inspectable()) return
if (store.comment) closeComment()
setPicking(tab.id, !picking())
}
const closeComment = () => {
const current = store.comment
if (!current) return
registration()?.highlight(current.tabID)
setStore("comment", undefined)
}
const submitComment = (value: string) => {
const current = store.comment
if (!current) return
const tab = state()
// The draft outlives a reload or agent navigation, but the ref no longer names anything.
const live = tab?.id === current.tabID && tab.generation === current.generation
props.onComment?.({
tabID: current.tabID,
url: current.url,
title: current.title,
element: {
...(live ? { ref: current.element.ref } : {}),
selector: current.element.selector,
label: current.element.label,
...(current.element.role ? { role: current.element.role } : {}),
...(current.element.name ? { name: current.element.name } : {}),
...(current.element.text ? { text: current.element.text } : {}),
},
comment: value,
})
closeComment()
}
// The picked element in surface pixels, and the editor anchored below it, above it, or over it.
const spotlight = () => {
const rect = store.comment?.element.rect
if (!rect) return
const zoom = platform.webviewZoom?.() ?? 1
return { x: rect.x / zoom, y: rect.y / zoom, width: rect.width / zoom, height: rect.height / zoom }
}
const placement = () => {
const rect = spotlight()
if (!rect) return
const gap = 8
const width = Math.max(0, Math.min(400, store.size.width - gap * 2))
// The editor scrolls rather than growing past the surface, so its actions stay reachable.
const maxHeight = Math.max(0, store.size.height - gap * 2)
// Until the editor has been measured once, assume its default three-row height.
const height = Math.min(store.editorHeight || 176, maxHeight)
const left = Math.min(Math.max(gap, rect.x), Math.max(gap, store.size.width - width - gap))
const below = rect.y + rect.height + gap
const above = rect.y - gap - height
const top =
below + height <= store.size.height - gap
? below
: above >= gap
? above
: Math.max(gap, store.size.height - height - gap)
return { left, top, width, maxHeight }
}
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -65,8 +164,62 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
},
},
// Ctrl+Shift+C copies in the terminal, so only the focused page claims it, as in Chromium.
{
id: "browser.inspect",
title: language.t("command.browser.inspect"),
category: language.t("command.category.view"),
disabled: !props.visible || !inspectable(),
onSelect: toggleInspect,
},
])
createEffect(() => {
onCleanup(
props.browser.onInspect((event) => {
if (event.active) {
setStore("picking", event.tabID)
return
}
if (store.picking === event.tabID) setStore("picking", undefined)
if (!event.element) return
const tab = state()
if (!props.onComment || tab?.id !== event.tabID || !props.visible || !store.visible) {
registration()?.highlight(event.tabID)
return
}
setStore("comment", {
tabID: tab.id,
url: tab.url,
title: tab.title,
generation: tab.generation,
element: event.element,
draft: "",
})
}),
)
})
// A picker or comment belongs to the page on screen; switching tabs or hiding the pane ends it.
createEffect(
on([() => state()?.id, () => props.visible && store.visible], ([id, shown]) => {
const tabID = untrack(() => store.picking)
if (tabID && (tabID !== id || !shown)) setPicking(tabID, false)
if (untrack(() => store.comment)?.tabID !== id) closeComment()
}),
)
// The page does not have focus while the picker waits for a hover, so Escape reaches the app.
createEventListener(
window,
"keydown",
(event) => {
if (event.key !== "Escape" || !store.picking) return
event.preventDefault()
event.stopPropagation()
setPicking(store.picking, false)
},
{ capture: true },
)
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
@@ -129,7 +282,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
// A comment on a picked element freezes the page so its editor can float above it.
const cover = store.comment?.tabID === tab.id || covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
@@ -202,6 +356,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
() => store.comment?.tabID,
empty,
failed,
registration,
@@ -220,7 +375,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
)
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
// animation frame keeps the native view in step with a pane drag.
createResizeObserver(() => surface, measure)
createResizeObserver(
() => surface,
(rect) => {
measure()
setStore("size", { width: rect.width, height: rect.height })
},
)
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
@@ -290,6 +451,39 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}
/>
</Tooltip>
<Show when={props.onComment}>
<Tooltip
placement="top"
value={
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span>{language.t("session.browser.inspect")}</span>
<Show when={command.keybindParts("browser.inspect").length > 0}>
<Keybind keys={command.keybindParts("browser.inspect")} variant="neutral" />
</Show>
</div>
{/* The page claims Chromium's picker chord itself; the app leaves it to the terminal. */}
<div class="flex items-center gap-2">
<span>{language.t("session.browser.inspect.pageShortcut")}</span>
<Keybind keys={formatKeybindParts("mod+shift+c", language.t)} variant="neutral" />
</div>
</div>
}
>
{/* The ghost variant sets the button color, so the active accent needs precedence over it. */}
<IconButton
{...button}
data-action="browser-inspect"
disabled={!inspectable()}
state={picking() ? "pressed" : undefined}
classList={{ "!text-v2-icon-icon-accent": picking() || !!store.comment }}
aria-pressed={picking()}
aria-label={language.t("session.browser.inspect")}
onClick={toggleInspect}
icon={<Icon name="select-element" size="small" />}
/>
</Tooltip>
</Show>
<form
dir="ltr"
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
@@ -378,7 +572,71 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{language.t("session.browser.suspended")}
</p>
</Show>
<Show when={store.comment?.tabID === state()?.id && store.comment}>
{(current) => (
<div
data-component="browser-comment"
class="absolute inset-0 z-10"
onPointerDown={(event) => {
// A click beside the editor dismisses it unless it would discard a draft.
if (event.target === event.currentTarget && !current().draft.trim()) closeComment()
}}
>
<Show when={spotlight()}>
{(rect) => (
<div
data-slot="browser-comment-spotlight"
class="pointer-events-none absolute rounded-[2px]"
style={{
left: `${rect().x}px`,
top: `${rect().y}px`,
width: `${rect().width}px`,
height: `${rect().height}px`,
}}
/>
)}
</Show>
<Show when={placement()}>
{(position) => (
<div
ref={(element) =>
createResizeObserver(element, (rect) => setStore("editorHeight", Math.ceil(rect.height)))
}
data-slot="browser-comment-editor"
data-prevent-autofocus
class="absolute overflow-y-auto rounded-[6px] shadow-[var(--v2-elevation-raised)]"
style={{
left: `${position().left}px`,
top: `${position().top}px`,
width: `${position().width}px`,
"max-height": `${position().maxHeight}px`,
}}
>
<LineCommentEditor
value={current().draft}
onInput={(value) => setStore("comment", "draft", value)}
onCancel={closeComment}
onSubmit={submitComment}
mention={props.mention}
selection={
<span class="flex min-w-0 items-center gap-1" dir="ltr">
<Icon name="select-element" size="small" class="shrink-0" />
<span class="min-w-0 truncate leading-[var(--line-height-tight)]">
{current().element.label}
</span>
</span>
}
/>
</div>
)}
</Show>
</div>
)}
</Show>
</div>
<p class="sr-only" role="status" aria-live="polite">
{picking() ? language.t("session.browser.inspect.active") : ""}
</p>
</aside>
)
}
@@ -9,6 +9,7 @@ import { showToast } from "@/shell/notifications/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode/util/encode"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
@@ -75,16 +76,7 @@ export const DialogFork: Component = () => {
dialog.close()
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
@@ -18,6 +18,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { createSessionBrowser } from "../browser/model"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
@@ -34,6 +35,7 @@ export function createActiveSessionRegion(input: {
screen: SessionScreenLayout
timeline: SessionTimelineInteraction
visible: Accessor<boolean>
browser: Pick<ReturnType<typeof createSessionBrowser>, "reveal">
}) {
const command = useCommand()
const dialog = useDialog()
@@ -46,6 +48,7 @@ export function createActiveSessionRegion(input: {
const state = createSessionRequestModel()
const controls = createComposerControls({
sessionKey: input.session.identity.sessionKey,
browser: { reveal: input.browser.reveal },
})
let promptRef: HTMLDivElement | undefined
@@ -29,6 +29,7 @@ import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import { useCommand } from "@/shell/commands/command"
import { useComposerState } from "@/composer/persistence"
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
@@ -80,6 +81,7 @@ export function SessionSidePanel(props: {
const layout = useLayout()
const settings = useSettings()
const file = useFile()
const prompt = useComposerState()
const language = useLanguage()
const command = useCommand()
const sdk = useWorkspaceLocation()
@@ -614,6 +616,10 @@ export function SessionSidePanel(props: {
<SessionBrowserPane
browser={props.browser}
visible={reviewOpen() && isSessionBrowserTab(activeTab())}
mention={{ items: file.searchFilesAndDirectories }}
onComment={(comment) =>
prompt.context.add({ type: "browser", ...comment, commentID: crypto.randomUUID() })
}
/>
</div>
</Show>
+2 -10
View File
@@ -4,6 +4,7 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { commentContextItem } from "@/composer/comment-note"
import { extractPromptComments, extractPromptFromMessage } from "@/composer/prompt"
import { showToast } from "@/shell/notifications/toast"
import type { SessionModel } from "./model"
@@ -34,16 +35,7 @@ export function createSessionRevert(input: {
directory: location().directory,
}),
)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
target.context.replaceComments(extractPromptComments(message).map(commentContextItem))
}
const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => {
+1
View File
@@ -178,6 +178,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
screen,
timeline,
visible: conversationVisible,
browser,
})
useUsageExceededDialogs()
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
@@ -38,3 +38,31 @@ describe("usage exceeded preferences", () => {
})
})
})
describe("ChatGPT usage limit", () => {
const message =
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
test("detects the mapped message regardless of failure type", () => {
expect(
isChatGPTUsageLimit({
type: "provider.rate-limit",
message,
status: 429,
}),
).toBe(true)
expect(isChatGPTUsageLimit({ type: "provider.unknown", message })).toBe(true)
})
test("ignores other rate limits and similar messages", () => {
expect(isChatGPTUsageLimit({ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 })).toBe(
false,
)
expect(
isChatGPTUsageLimit({
type: "provider.rate-limit",
message: "ChatGPT usage limit reached",
}),
).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
@@ -14,6 +14,14 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) {
return (
error.message ===
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
)
}
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
@@ -52,6 +60,25 @@ export function useUsageExceededDialogs() {
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})
const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted(
Persist.global("chatgpt-usage-limit"),
Persistence.struct({ lastSeenAt: Schema.NullOr(Schema.Finite) }),
{ lastSeenAt: null },
)
onCleanup(
sdk().event.on("session.step.failed", (evt) => {
if (evt.data.sessionID !== params.id) return
if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return
if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return
void import("@/providers/connect/chatgpt-usage-limit").then((usage) => {
if (dialog.active) return
setChatGPTUsageLimit("lastSeenAt", Date.now())
dialog.show(() => <usage.DialogChatGPTUsageLimit />)
})
}),
)
onCleanup(
sdk().event.on("session.status", (evt) => {
@@ -0,0 +1,94 @@
import { afterAll, beforeAll, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode/client/solid"
import type { ServerConnection } from "@/runtime/server/registry"
import { ServerScope } from "@/runtime/server/scope"
import type { Tab } from "@/shell/tabs/tabs"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
const alerts: string[] = []
const tabs: { store: Tab[] } = { store: [] }
let createServerNotificationState: typeof import("./notification").createServerNotificationState
let storage: typeof import("@/runtime/persistence/storage")
beforeAll(async () => {
storage = await import("@/runtime/persistence/storage")
const { sessionIDHasOpenTab } = await import("@/shell/tabs/tabs")
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
notify: async (title: string) => {
alerts.push(title)
},
}),
}))
mock.module("@/settings/model", () => ({
useSettings: () => ({
sounds: { agentEnabled: () => false, errorsEnabled: () => false },
notifications: { agent: () => true, errors: () => true },
}),
}))
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
mock.module("@/shell/tabs/tabs", () => ({
useTabs: () => tabs,
sessionIDHasOpenTab,
}))
mock.module("@/runtime/persistence/storage", () => ({
...storage,
persisted: () => {
const [store, setStore] = createStore({ list: [] })
return [store, setStore, undefined, () => false]
},
}))
createServerNotificationState = (await import("./notification")).createServerNotificationState
})
afterAll(() => mock.module("@/runtime/persistence/storage", () => storage))
test.each([
["session.execution.succeeded", "notification.session.responseReady.title"],
["session.execution.failed", "notification.session.error.title"],
] as const)("system alert for %s requires an open session tab", async (type, title) => {
alerts.length = 0
tabs.store = [{ type: "session", server, sessionId: "another-session" }]
let listener: ((event: unknown) => void) | undefined
const dispose = createRoot((dispose) => {
const state = createServerNotificationState({
key: server,
sdk: {
scope: ServerScope.local,
event: {
listen: (fn: typeof listener) => {
listener = fn
return () => {}
},
},
} as unknown as ServerSDK,
data: { session: { get: () => session } } as unknown as Data,
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
} as Parameters<typeof createServerNotificationState>[0])
return { dispose, state }
})
listener?.({
type,
id: "event-1",
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(dispose.state.session.all(session.id)).toHaveLength(1)
expect(alerts).toEqual([])
tabs.store = [{ type: "session", server, sessionId: session.id }]
listener?.({
type,
id: "event-2",
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(alerts).toEqual([title])
dispose.dispose()
})
@@ -228,7 +228,8 @@ export function createServerNotificationState(input: {
if (!session) return
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
if (hasOpenTab && settings.sounds.agentEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
@@ -240,7 +241,7 @@ export function createServerNotificationState(input: {
session: sessionID,
})
if (settings.notifications.agent()) {
if (hasOpenTab && settings.notifications.agent()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
@@ -255,7 +256,8 @@ export function createServerNotificationState(input: {
if (meta.disposed) return
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
if (hasOpenTab && settings.sounds.errorsEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
@@ -270,7 +272,7 @@ export function createServerNotificationState(input: {
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
if (hasOpenTab && settings.notifications.errors()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
@@ -10,6 +10,8 @@ import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export function SessionUIProvider(
props: ParentProps<{
@@ -22,6 +24,7 @@ export function SessionUIProvider(
const data = useData()
const serverSDK = useServerSDK()
const tabs = useTabs()
const language = useLanguage()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory()
@@ -39,6 +42,17 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const openReferencedSession = async (sessionID: string) => {
// The transcript may mention a session from another server (or one that was deleted).
// Resolve it on this server before touching tabs or the current route.
const session = await serverSDK.api.session.get({ sessionID }).catch(() => undefined)
if (!session || session.time.archived) {
showToast({ title: language.t("session.error.notFound") })
return
}
data.session.remember(session)
tabs.select(tabs.addSessionTab({ server: props.server, sessionId: session.id }))
}
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
@@ -66,7 +80,7 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<MarkdownProvider readImage={readImage()}>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider>
</DataProvider>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"bin": {
+139
View File
@@ -0,0 +1,139 @@
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import type { ConfigOptionProvider } from "./config-option"
export type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
}
export type Live = {
readonly cwd: string
current: Catalog
}
// Provider, integration, and credential changes reach the catalog through model.updated.
const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated", "command.updated"])
export function make(input: {
readonly client: OpenCodeClient
readonly signal?: AbortSignal
readonly changed: (live: Live, previous: Catalog) => Promise<unknown>
}) {
const entries = new Map<string, Promise<Live>>()
const running = new Map<Live, Promise<void>>()
const queued = new Set<Live>()
let subscribed: Promise<void> | undefined
const get = (cwd: string) => {
const key = FSUtil.resolve(cwd)
const cached = entries.get(key)
if (cached) return cached
// Subscribe before the first read so an update between the read and the subscription is not lost.
const loaded = subscribe()
.then(() => load(input.client, cwd))
.then((current): Live => ({ cwd, current }))
.catch((error) => {
entries.delete(key)
throw error
})
entries.set(key, loaded)
return loaded
}
const reload = (live: Live) => {
const current = running.get(live)
if (current) {
queued.add(live)
return current
}
const run = (async () => {
do {
queued.delete(live)
const next = await load(input.client, live.cwd).catch(() => undefined)
if (!next) break
const previous = live.current
live.current = next
await input.changed(live, previous).catch(() => {})
} while (queued.has(live))
running.delete(live)
})()
running.set(live, run)
return run
}
const subscribe = () =>
(subscribed ??= new Promise<void>((ready) => {
void (async () => {
for await (const event of input.client.event.subscribe({ signal: input.signal })) {
if (event.type === "server.connected") ready()
if (!reloadOn.has(event.type)) continue
const directory = event.location?.directory
const targets = directory === undefined ? [...entries.values()] : [entries.get(FSUtil.resolve(directory))]
targets.forEach((entry) => void entry?.then(reload, () => {}))
}
})()
.catch(() => {})
.finally(ready)
}))
return { get, reload }
}
async function load(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Some providers discover models in the background after plugin startup begins.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
export * as ACPCatalog from "./catalog"
+124 -158
View File
@@ -1,7 +1,8 @@
import { isDeepStrictEqual } from "node:util"
import {
isMcpServerNotFoundError,
isSessionNotFoundError,
type CommandInfo,
type ModelInfo,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
@@ -40,12 +41,8 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { ACPCatalog, type Catalog } from "./catalog"
import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
import type { ACPConnection } from "./connection"
import { promptContentToParts } from "./content"
import {
@@ -61,22 +58,14 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
readonly defaultModel: ModelRef
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
}
// Model and mode are unset while the session follows the server defaults.
type Attached = {
readonly id: string
readonly cwd: string
readonly abort: AbortController
catalog: Catalog
model: ModelRef
modeID: string
readonly catalog: ACPCatalog.Live
model?: ModelRef
modeID?: string
}
type PreparedPrompt = {
@@ -109,21 +98,55 @@ export function make(input: {
readonly connection: ACPConnection.Connection
}): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
if (cached) return cached
const loaded = loadCatalog(input.client, cwd).catch((error) => {
catalogs.delete(cwd)
throw error
const catalogs = ACPCatalog.make({
client: input.client,
signal: input.connection.signal,
changed: (live, previous) =>
Promise.all(
Array.from(sessions.values())
.filter((state) => state.catalog === live)
.map(async (state) => {
const options = configOptions(state)
if (!isDeepStrictEqual(options, configOptions(state, previous))) {
await input.connection.sessionUpdate({
sessionId: state.id,
update: { sessionUpdate: "config_option_update", configOptions: options },
})
}
if (!isDeepStrictEqual(live.current.commands, previous.commands)) await sendCommands(state)
}),
),
})
const sendCommands = (state: Attached) =>
input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: state.catalog.current.commands.map((command) => ({
name: command.name,
description: command.description ?? "",
})),
},
})
const withReload = <A>(state: Attached, select: () => Promise<A>) =>
select().catch(async (error: unknown) => {
if (
!(
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidModeError ||
error instanceof ACPError.InvalidEffortError
)
)
throw error
await catalogs.reload(state.catalog)
return select()
})
catalogs.set(cwd, loaded)
return loaded
}
const requireSession = async (sessionID: string) => {
const current = sessions.get(sessionID)
@@ -147,27 +170,19 @@ export function make(input: {
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
const catalog = await catalogs.get(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
abort: new AbortController(),
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
catalog,
model: session.model,
modeID: session.agent,
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
],
},
})
await sendCommands(state)
return state
}
@@ -175,14 +190,16 @@ export function make(input: {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached) =>
buildConfigOptions({
providers: state.catalog.providers,
currentModel: { providerID: state.model.providerID, modelID: state.model.id },
currentVariant: state.model.variant,
modes: state.catalog.modes,
currentModeId: state.modeID,
const configOptions = (state: Attached, catalog = state.catalog.current) => {
const model = currentModel(state, catalog)
return buildConfigOptions({
providers: catalog.providers,
currentModel: { providerID: model.providerID, modelID: model.id },
currentVariant: model.variant,
modes: catalog.modes,
currentModeId: state.modeID ?? catalog.defaultModeID,
})
}
return {
initialize: async (params) => {
@@ -216,12 +233,10 @@ export function make(input: {
return {}
},
newSession: async (params) => {
const currentCatalog = await catalog(params.cwd)
const created = await input.client.session.create({
location: { directory: params.cwd },
agent: currentCatalog.defaultModeID,
model: currentCatalog.defaultModel,
})
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
// so the server resolves its defaults after plugins activate.
await catalogs.get(params.cwd)
const created = await input.client.session.create({ location: { directory: params.cwd } })
const state = await attach(created, params.cwd, params.mcpServers)
return { sessionId: state.id, configOptions: configOptions(state) }
},
@@ -266,6 +281,7 @@ export function make(input: {
if (!isSessionNotFoundError(error)) throw error
})
await turn?.turn.catch(() => {})
await releaseMcpServers(input.client, registeredMcp, params.sessionId)
detach(params.sessionId)
return {}
},
@@ -279,37 +295,38 @@ export function make(input: {
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value, state.model)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
const value = params.value
if (typeof value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
await withReload(state, async () => {
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog.current, value, currentModel(state))
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
return
}
case "effort": {
const current = currentModel(state)
const model = state.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
throw new ACPError.InvalidEffortError({ effort: value })
state.model = { ...current, variant: value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
return
}
case "mode":
return selectMode(input.client, state, value)
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
})
return { configOptions: configOptions(state) }
},
setSessionMode: async (params) => {
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
const state = await requireSession(params.sessionId)
await withReload(state, () => selectMode(input.client, state, params.modeId))
return {}
},
prompt: async (params, signal) => {
@@ -321,7 +338,7 @@ export function make(input: {
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const prepared = preparePrompt(state.catalog.current, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
const childSessionUpdate =
@@ -410,58 +427,6 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart
return { type: "input", id: messageID }
}
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Some providers discover models in the background after plugin startup begins.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
// Parallel reads can straddle initialization; select only from this model list.
const defaultModel = preferred
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
: models[0]
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
const defaultAgent = agents.find((agent) => agent.mode === "primary") ?? agents[0]
if (defaultModel && defaultAgent) {
return {
providers: providers(models),
models,
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
await Bun.sleep(25)
}
throw new Error(missing)
}
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
return Array.from(new Set(models.map((model) => model.providerID)))
.toSorted()
.map((providerID) => ({
id: providerID,
name: providerID,
models: models
.filter((model) => model.providerID === providerID)
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
}))
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
@@ -480,8 +445,13 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
return { providerID: model.providerID, id: model.id, variant }
}
function currentModel(state: Attached, catalog = state.catalog.current) {
return state.model ?? catalog.defaultModel
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
if (!state.catalog.current.modes.some((mode) => mode.id === modeID))
throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
@@ -519,21 +489,25 @@ async function registerMcpServers(
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
servers.map(async (server) => {
await client.session.mcp.add({ sessionID: session.id, server: server.name, config: mcpConfig(server) })
current.add(server.name)
}),
)
}
async function releaseMcpServers(client: OpenCodeClient, registered: Map<string, Set<string>>, sessionID: string) {
const servers = Array.from(registered.get(sessionID) ?? [])
registered.delete(sessionID)
await Promise.all(
servers.map((server) =>
client.session.mcp.remove({ sessionID, server }).catch((error) => {
if (!isSessionNotFoundError(error) && !isMcpServerNotFoundError(error)) throw error
}),
),
)
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
@@ -551,15 +525,6 @@ function mcpConfig(server: McpServer) {
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
async function sendUsageUpdate(
client: OpenCodeClient,
connection: ACPConnection.Connection,
@@ -567,8 +532,9 @@ async function sendUsageUpdate(
used?: number,
) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
const current = currentModel(session)
const model = session.catalog.current.models.find(
(item) => item.providerID === current.providerID && item.id === current.id,
)
if (!model?.limit.context) return
const info = await client.session.get({ sessionID: session.id })
+20
View File
@@ -183,6 +183,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
},
}),
Spec.make("export", {
description: "print stored credentials, including secrets, as JSON",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name (exports every integration when omitted)"),
Argument.optional,
),
},
}),
Spec.make("import", {
description: "import credentials exported by auth export",
params: {
...ServerParams,
file: Argument.string("file").pipe(
Argument.withDescription("JSON file to import (reads stdin when omitted)"),
Argument.optional,
),
},
}),
Spec.make("switch", {
description: "switch the active account for an integration",
params: {
@@ -0,0 +1,31 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations, request, resolveIntegration } from "./shared"
import { errorMessage } from "../../../util/error"
export default Runtime.handler(
Commands.commands.auth.commands.export,
Effect.fn("cli.auth.export")(
function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const target = Option.getOrUndefined(input.target)
const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined
const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter(
(credential) => !integrationID || credential.integrationID === integrationID,
)
if (process.stdout.isTTY)
process.stderr.write(
"Warning: the output contains secrets; redirect it to a file or pipe it to auth import" + EOL,
)
process.stdout.write(JSON.stringify(credentials, null, 2) + EOL)
},
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -0,0 +1,58 @@
import { EOL } from "node:os"
import { isConflictError, type CredentialCreateInput } from "@opencode/client"
import { Credential } from "@opencode/schema/credential"
import { Effect, Option, Schema } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, request } from "./shared"
import { errorMessage } from "../../../util/error"
import { readStdin } from "../../../util/io"
export default Runtime.handler(
Commands.commands.auth.commands.import,
Effect.fn("cli.auth.import")(
function* (input) {
const file = Option.getOrUndefined(input.file)
if (!file && process.stdin.isTTY)
return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import"))
const text = yield* request(() => (file ? Bun.file(file).text() : readStdin()))
const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text)
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const existing = yield* request((signal) => client.credential.list({ signal }))
const ids = new Set(existing.map((credential) => credential.id))
const integrations = new Set(existing.map((credential) => credential.integrationID))
const results = yield* Effect.forEach(credentials, (credential) => {
if (ids.has(credential.id)) return Effect.succeed(false)
return request((signal) =>
client.credential.create(
{
id: credential.id,
integrationID: credential.integrationID,
label: credential.label,
// Stored metadata is JSON, but the credential schema types it as unknown while the generated client expects JSON.
value: credential.value as CredentialCreateInput["value"],
// Keep the destination's current selections; only integrations new to it adopt the exported selection.
activate: credential.active && !integrations.has(credential.integrationID),
},
{ signal },
),
).pipe(
Effect.as(true),
Effect.catchIf(isConflictError, () => Effect.succeed(false)),
)
})
const imported = results.filter(Boolean).length
process.stderr.write(
`Imported ${imported} ${imported === 1 ? "credential" : "credentials"}` +
(results.length > imported ? `, skipped ${results.length - imported} already present` : "") +
EOL,
)
},
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
@@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { redactConfig } from "./redact"
export default Runtime.handler(
Commands.commands.debug.commands.config,
Effect.fn("cli.debug.config")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
@@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm } from "../auth/form"
@@ -21,7 +21,7 @@ export default Runtime.handler(
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
intro("Authenticate an MCP server")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integrations = yield* loadIntegrations(client)
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
@@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
@@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
@@ -12,7 +12,7 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
if ((yield* ServiceConfig.read()).disabled === true)
return yield* Effect.fail(
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
@@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Npm } from "@opencode/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export interface Item {
readonly runtime: "Server" | "TUI"
@@ -16,7 +16,7 @@ export interface Item {
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
@@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { Config } from "../../../config"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
@@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const config = yield* Config.Service
+2
View File
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
export: () => import("./commands/handlers/auth/export"),
import: () => import("./commands/handlers/auth/import"),
switch: () => import("./commands/handlers/auth/switch"),
},
debug: {
+48 -9
View File
@@ -67,6 +67,12 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
// attached client must not cancel input that may belong to another session.
const GLOBAL_FORM_SESSION_ID = "global"
const QUESTION_CANCELLED_FEEDBACK =
"This non-interactive run cannot ask the user questions, so the question was cancelled. Continue without an answer; make reasonable assumptions and state them."
const PERMISSION_REJECTED_FEEDBACK =
"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
@@ -132,10 +138,34 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
// Subagents run in child sessions; their asks and questions belong to this run too. Other
// sessions on a shared server (e.g. the TUI's) must be left alone.
const owned = new Map<string, Promise<boolean>>([[input.sessionID, Promise.resolve(true)]])
const ownsSession = (sessionID: string): Promise<boolean> => {
const known = owned.get(sessionID)
if (known) return known
const result =
sessionID === GLOBAL_FORM_SESSION_ID
? Promise.resolve(false)
: input.client.session
.get({ sessionID })
.then((session) => (session.parentID ? ownsSession(session.parentID) : false))
.catch(() => false)
owned.set(sessionID, result)
return result
}
const replyPermission = async (request: {
id: string
sessionID: string
action: string
resources: ReadonlyArray<string>
}) => {
// Nobody can approve here. Outside V1 compatibility, reject with feedback so the tool fails
// as ordinary model-visible output and the model continues without the action.
const continuing = !input.auto && input.compatibility !== "v1"
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
if (!continuing) permissionRejected = true
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -144,25 +174,34 @@ export async function runNonInteractivePrompt(input: Input) {
}
await input.client.permission
.reply({
sessionID: input.sessionID,
sessionID: request.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
...(continuing ? { message: PERMISSION_REJECTED_FEEDBACK } : {}),
})
.catch(() => {})
if (!input.auto) {
if (!input.auto && !continuing) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID" | "metadata">) => {
// Nobody can answer a question here. Outside V1 compatibility, cancel it with a message so the
// question tool fails as ordinary model-visible output and the model continues without an answer.
const continuing = request.metadata?.kind === "question" && input.compatibility !== "v1"
try {
await input.client.session.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
{
sessionID: request.sessionID,
formID: request.id,
...(continuing ? { message: QUESTION_CANCELLED_FEEDBACK } : {}),
},
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
if (continuing) return
formCancelled = true
if (input.compatibility !== "v1") process.exitCode = 1
}
@@ -179,14 +218,14 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "permission.asked" && submitted && (await ownsSession(event.data.sessionID))) {
await replyPermission(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
((await ownsSession(event.data.form.sessionID)) ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
@@ -18,7 +18,7 @@ export type Resolved = {
readonly service?: ReturnType<typeof managedService>
}
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) {
@@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
)
return { endpoint } satisfies Resolved
}
if (args.standalone) {
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
+25 -2
View File
@@ -12,6 +12,7 @@ import { selfCommand } from "../util/process"
// registration file (by channel), which version, and how to spawn opencode.
export const Info = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
@@ -20,7 +21,7 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password", "cors", "env"] as const
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
if (
key === "disabled" ||
key === "hostname" ||
key === "port" ||
key === "password" ||
key === "cors" ||
key === "env"
)
return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "disabled": {
return String((yield* read()).disabled ?? false)
}
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "disabled": {
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
if (value === "true") yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), disabled: value === "true" })
return
}
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "disabled": {
const { disabled: _disabled, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
+161 -41
View File
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
import {
buildAgent,
makeACPFixture,
makeSession,
planAgent,
reviewCommand,
secondModel,
testModel,
} from "./service-fixture"
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
describe("acp service directory behavior", () => {
@@ -27,12 +35,7 @@ describe("acp service directory behavior", () => {
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
[
"/api/model",
"/api/model/default",
"/api/agent",
"/api/command",
].map((path) =>
["/api/model", "/api/model/default", "/api/agent", "/api/command"].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
@@ -48,21 +51,9 @@ describe("acp service directory behavior", () => {
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/workspace" },
},
{
agent: "build",
model: { providerID: "test", id: "test-model", variant: "default" },
location: { directory: "/other" },
},
{ location: { directory: "/workspace" } },
{ location: { directory: "/workspace" } },
{ location: { directory: "/other" } },
])
expect(
fixture.updates.map((item) =>
@@ -70,11 +61,116 @@ describe("acp service directory behavior", () => {
? item.update.availableCommands.map((command) => command.name)
: [],
),
).toEqual([
["review"],
["review"],
["review"],
])
).toEqual([["review"], ["review"], ["review"]])
})
test("follows server defaults and refreshes the catalog when location plugins finish activating", async () => {
const configured = { ...buildAgent, id: "copilot-build", name: "copilot-build" }
const catalog = { agents: [buildAgent, planAgent], commands: [reviewCommand] }
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
const location = { directory: request.query["location[directory]"] ?? "/workspace" }
if (request.path === "/api/agent") return Response.json({ location, data: catalog.agents })
if (request.path === "/api/command") return Response.json({ location, data: catalog.commands })
if (request.method !== "POST" || request.path !== "/api/session") return undefined
created++
return Response.json({ data: { ...makeSession(`ses_${created}`), agent: undefined, model: undefined } })
},
})
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(currentValue(first, "mode")).toBe("build")
expect(currentValue(first, "model")).toBe("test/test-model")
const agentReads = () => fixture.requests.filter((request) => request.path === "/api/agent").length
const reads = agentReads()
fixture.send({ id: "evt_other", created: 1, type: "agent.updated", location: { directory: "/other" }, data: {} })
catalog.agents = [configured, buildAgent, planAgent]
catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
fixture.send({
id: "evt_agent",
created: 2,
type: "agent.updated",
location: { directory: "/workspace" },
data: {},
})
const update = await until(() =>
fixture.updates.find(
(item) => item.sessionId === first.sessionId && item.update.sessionUpdate === "config_option_update",
),
)
expect(update.update.sessionUpdate === "config_option_update" && modeOption(update.update.configOptions)).toEqual({
currentValue: "copilot-build",
options: ["copilot-build", "build", "plan"],
})
const commands = await until(() =>
fixture.updates.findLast(
(item) =>
item.sessionId === first.sessionId &&
item.update.sessionUpdate === "available_commands_update" &&
item.update.availableCommands.length === 2,
),
)
expect(
commands.update.sessionUpdate === "available_commands_update" &&
commands.update.availableCommands.map((command) => command.name),
).toEqual(["review", "ship"])
expect(agentReads()).toBe(reads + 1)
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(currentValue(second, "mode")).toBe("copilot-build")
expect(
fixture.requests
.filter((request) => request.method === "POST" && request.path === "/api/session")
.map((request) => request.body),
).toEqual([{ location: { directory: "/workspace" } }, { location: { directory: "/workspace" } }])
})
test("reloads the catalog before rejecting a model or mode it has not seen", async () => {
const configured = { ...planAgent, id: "copilot-build", name: "copilot-build" }
const catalog = { models: [testModel], agents: [buildAgent, planAgent] }
await using fixture = makeACPFixture({
defaultModel: testModel,
fetch(request) {
const location = { directory: request.query["location[directory]"] ?? "/workspace" }
if (request.path === "/api/model") return Response.json({ location, data: catalog.models })
if (request.path === "/api/agent") return Response.json({ location, data: catalog.agents })
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_reload") })
}
if (
request.method === "POST" &&
(request.path === "/api/session/ses_reload/model" || request.path === "/api/session/ses_reload/agent")
) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
catalog.models = [testModel, secondModel]
catalog.agents = [buildAgent, planAgent, configured]
const model = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "copilot-build" })
const missing = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "mode", value: "missing" })
.catch((error: unknown) => error)
expect(currentValue(model, "model")).toBe("test/second-model")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_reload/agent")
.map((request) => request.body),
).toEqual([{ agent: "copilot-build" }])
expect(missing).toMatchObject({ _tag: "ACPInvalidModeError" })
expect(fixture.requests.filter((request) => request.path === "/api/agent")).toHaveLength(3)
})
test.each(["empty", "missing the default"])(
@@ -221,7 +317,7 @@ describe("acp service directory behavior", () => {
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
test("registers MCP configs for the session on every attach", async () => {
const local: McpServer = {
name: "tools",
command: "bun",
@@ -236,7 +332,7 @@ describe("acp service directory behavior", () => {
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
const mcp = "/api/experimental/mcp/"
const mcp = /^\/api\/experimental\/session\/(ses_\d+)\/mcp\/(\w+)$/
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -246,21 +342,35 @@ describe("acp service directory behavior", () => {
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith(mcp)) {
if (request.method === "PUT" && mcp.test(request.path)) {
return new Response(null, { status: 204 })
}
return undefined
},
})
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
const adds = fixture.requests.filter((request) => request.method === "PUT" && mcp.test(request.path))
expect(adds.map((request) => request.path.match(mcp)?.slice(1))).toEqual([
["ses_1", "tools"],
["ses_1", "docs"],
["ses_1", "tools"],
["ses_1", "docs"],
["ses_1", "tools"],
["ses_2", "tools"],
])
expect(adds.filter((request) => request.path.endsWith("/mcp/tools")).map((request) => request.body)).toEqual([
{
config: {
type: "local",
command: ["bun", "server.ts"],
environment: { TOKEN: "x" },
},
},
{
config: {
type: "local",
@@ -283,7 +393,7 @@ describe("acp service directory behavior", () => {
},
},
])
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
expect(adds.find((request) => request.path.endsWith("/mcp/docs"))?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
@@ -291,15 +401,25 @@ describe("acp service directory behavior", () => {
oauth: false,
},
})
expect(adds.map((request) => request.query)).toEqual([
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
{ "location[directory]": "/workspace" },
])
expect(adds.map((request) => request.query)).toEqual([{}, {}, {}, {}, {}, {}])
})
})
function modeOption(options: SessionConfigOption[]) {
const mode = requireSelectOption(options, "mode")
return { currentValue: mode.currentValue, options: flattenSelectOptions(mode).map((option) => option.value) }
}
async function until<Value>(read: () => Value | undefined) {
const deadline = Date.now() + 2_000
while (Date.now() < deadline) {
const value = read()
if (value !== undefined) return value
await Bun.sleep(5)
}
throw new Error("timed out waiting for ACP update")
}
function currentValue(
result: { readonly configOptions?: readonly SessionConfigOption[] | null } | undefined,
id: string,
+1
View File
@@ -184,6 +184,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
service,
requests,
updates,
send: context.send,
async [Symbol.asyncDispose]() {
eventController?.close()
await server.stop(true)
@@ -26,11 +26,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session",
query: {},
body: {
location: { directory: "/workspace" },
agent: "build",
model: { providerID: "test", id: "second-model" },
},
body: { location: { directory: "/workspace" } },
})
expect(currentValue(created, "effort")).toBe("default")
})
@@ -299,6 +295,46 @@ describe("acp service lifecycle", () => {
.catch((error: unknown) => error)
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
})
test("releases session MCP servers on close and leaves deletion cleanup to the server", async () => {
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_${created}`) })
}
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.path.startsWith("/api/experimental/session/")) return new Response(null, { status: 204 })
if (request.method === "POST" && request.path.endsWith("/interrupt"))
return Response.json({ interrupted: false })
if (request.method === "DELETE" && request.path === "/api/session/ses_2") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const server = { name: "ctx", command: "bun", args: ["ctx.ts"], env: [] }
const mcpRequests = () =>
fixture.requests
.filter((request) => request.path.startsWith("/api/experimental/session/"))
.map((request) => `${request.method} ${request.path}`)
const closed = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [server] })
const deleted = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [server] })
await fixture.service.closeSession({ sessionId: closed.sessionId })
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: closed.sessionId, mcpServers: [server] })
await fixture.service.deleteSession({ sessionId: deleted.sessionId })
expect(mcpRequests()).toEqual([
"PUT /api/experimental/session/ses_1/mcp/ctx",
"PUT /api/experimental/session/ses_2/mcp/ctx",
"DELETE /api/experimental/session/ses_1/mcp/ctx",
"PUT /api/experimental/session/ses_1/mcp/ctx",
])
})
})
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
+2 -2
View File
@@ -24,7 +24,7 @@ describe("acp service", () => {
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
if (url.pathname === "/api/experimental/session/ses_acp/mcp/docs" && request.method === "PUT")
return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
@@ -54,7 +54,7 @@ describe("acp service", () => {
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/experimental/mcp/docs",
path: "/api/experimental/session/ses_acp/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
+1 -1
View File
@@ -38,7 +38,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
pid: process.pid,
}),
)
const resolved = await runPromise(ServerConnection.resolve({}))
const resolved = await runPromise(ServerConnection.resolve())
expect(resolved.endpoint.url).toBe(server.url.toString())
expect(resolved.service).toBeDefined()
+19 -1
View File
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
import { Global } from "@opencode/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, FileSystem, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -21,6 +21,24 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
try {
expect(await run(ServiceConfig.get("disabled"))).toBe("false")
await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false")
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("disabled", "true"))
expect(await run(ServiceConfig.read())).toEqual({ disabled: true })
await run(ServiceConfig.unset("disabled"))
expect(await run(ServiceConfig.read())).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"repository": {
+68 -7
View File
@@ -19,13 +19,13 @@ import type { Skill } from "@opencode/schema/skill"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { Mcp } from "@opencode/schema/mcp"
import type { Event } from "@opencode/schema/event"
import type { EventLog } from "@opencode/schema/event-log"
import type { Shell } from "@opencode/schema/shell"
import type { Provider } from "@opencode/schema/provider"
import type { Form } from "@opencode/schema/form"
import type { Integration } from "@opencode/schema/integration"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
@@ -411,6 +411,20 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: SessionInstructionsEntryRemoveInput,
) => Effect.Effect<SessionInstructionsEntryRemoveOutput, E>
export type SessionMcpAddInput = {
readonly sessionID: Session.ID
readonly server: string
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
}
export type SessionMcpAddOutput = void
export type SessionMcpAddOperation<E = never> = (input: SessionMcpAddInput) => Effect.Effect<SessionMcpAddOutput, E>
export type SessionMcpRemoveInput = { readonly sessionID: Session.ID; readonly server: string }
export type SessionMcpRemoveOutput = void
export type SessionMcpRemoveOperation<E = never> = (
input: SessionMcpRemoveInput,
) => Effect.Effect<SessionMcpRemoveOutput, E>
export type SessionGenerateInput = { readonly sessionID: Session.ID; readonly prompt: string }
export type SessionGenerateOutput = { readonly text: string }
export type SessionGenerateOperation<E = never> = (
@@ -710,7 +724,12 @@ export type SessionLogOutput =
| undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
}
}
| {
@@ -902,7 +921,12 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
@@ -1112,7 +1136,12 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly content?:
| readonly [
(
@@ -1157,7 +1186,12 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
}
}
| {
@@ -1238,7 +1272,12 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly inputID?: SessionMessage.ID | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
@@ -1389,7 +1428,11 @@ export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormCancelInput = {
readonly sessionID: string
readonly formID: Form.ID
readonly message?: string | undefined
}
export type SessionFormCancelOutput = void
export type SessionFormCancelOperation<E = never> = (
input: SessionFormCancelInput,
@@ -1448,6 +1491,7 @@ export interface SessionApi<E = never> {
readonly remove: SessionInstructionsEntryRemoveOperation<E>
}
}
readonly mcp: { readonly add: SessionMcpAddOperation<E>; readonly remove: SessionMcpRemoveOperation<E> }
readonly generate: SessionGenerateOperation<E>
readonly log: SessionLogOperation<E>
readonly interrupt: SessionInterruptOperation<E>
@@ -1718,6 +1762,21 @@ export interface McpApi<E = never> {
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
export type CredentialListOutput = ReadonlyArray<Credential.Entry>
export type CredentialListOperation<E = never> = () => Effect.Effect<CredentialListOutput, E>
export type CredentialCreateInput = {
readonly id?: Credential.ID | undefined
readonly integrationID: Integration.ID
readonly label?: string | undefined
readonly value: Credential.Value
readonly activate?: boolean | undefined
}
export type CredentialCreateOutput = Credential.Entry
export type CredentialCreateOperation<E = never> = (
input: CredentialCreateInput,
) => Effect.Effect<CredentialCreateOutput, E>
export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string }
export type CredentialUpdateOutput = void
export type CredentialUpdateOperation<E = never> = (
@@ -1737,6 +1796,8 @@ export type CredentialRemoveOperation<E = never> = (
) => Effect.Effect<CredentialRemoveOutput, E>
export interface CredentialApi<E = never> {
readonly list: CredentialListOperation<E>
readonly create: CredentialCreateOperation<E>
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
+53 -3
View File
@@ -83,6 +83,10 @@ import type {
SessionInstructionsEntryPutOutput,
SessionInstructionsEntryRemoveInput,
SessionInstructionsEntryRemoveOutput,
SessionMcpAddInput,
SessionMcpAddOutput,
SessionMcpRemoveInput,
SessionMcpRemoveOutput,
SessionGenerateInput,
SessionGenerateOutput,
SessionLogInput,
@@ -153,6 +157,9 @@ import type {
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialListOutput,
CredentialCreateInput,
CredentialCreateOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
@@ -654,6 +661,21 @@ const EndpointSessionInstructionsEntryRemove =
),
)
const EndpointSessionMcpAdd = (raw: RawClient["server.session"]) => (input: SessionMcpAddInput) =>
preserveEffect<SessionMcpAddOutput>()(
raw["session.mcp.add"]({
params: { sessionID: input["sessionID"], server: input["server"] },
payload: { config: input["config"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionMcpRemove = (raw: RawClient["server.session"]) => (input: SessionMcpRemoveInput) =>
preserveEffect<SessionMcpRemoveOutput>()(
raw["session.mcp.remove"]({ params: { sessionID: input["sessionID"], server: input["server"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionGenerate = (raw: RawClient["server.session"]) => (input: SessionGenerateInput) =>
preserveEffect<SessionGenerateOutput>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
@@ -732,9 +754,10 @@ const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: S
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
raw["session.form.cancel"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
query: { message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
@@ -792,6 +815,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
remove: EndpointSessionInstructionsEntryRemove(raw),
},
},
mcp: { add: EndpointSessionMcpAdd(raw), remove: EndpointSessionMcpRemove(raw) },
generate: EndpointSessionGenerate(raw),
log: EndpointSessionLog(raw),
interrupt: EndpointSessionInterrupt(raw),
@@ -1021,6 +1045,30 @@ const adaptGroupMcp = (raw: RawClient["server.mcp"]) => ({
resource: { catalog: EndpointMcpResourceCatalog(raw) },
})
const EndpointCredentialList = (raw: RawClient["server.credential"]) => () =>
preserveEffect<CredentialListOutput>()(
raw["credential.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointCredentialCreate = (raw: RawClient["server.credential"]) => (input: CredentialCreateInput) =>
preserveEffect<CredentialCreateOutput>()(
raw["credential.create"]({
payload: {
id: input["id"],
integrationID: input["integrationID"],
label: input["label"],
value: input["value"],
activate: input["activate"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input: CredentialUpdateInput) =>
preserveEffect<CredentialUpdateOutput>()(
raw["credential.update"]({
@@ -1042,6 +1090,8 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
)
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
list: EndpointCredentialList(raw),
create: EndpointCredentialCreate(raw),
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
@@ -77,6 +77,10 @@ import type {
SessionInstructionsEntryPutOutput,
SessionInstructionsEntryRemoveInput,
SessionInstructionsEntryRemoveOutput,
SessionMcpAddInput,
SessionMcpAddOutput,
SessionMcpRemoveInput,
SessionMcpRemoveOutput,
SessionGenerateInput,
SessionGenerateOutput,
SessionLogInput,
@@ -147,6 +151,9 @@ import type {
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialListOutput,
CredentialCreateInput,
CredentialCreateOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
@@ -943,6 +950,31 @@ export function make(options: ClientOptions) {
),
},
},
mcp: {
add: (input: SessionMcpAddInput, requestOptions?: RequestOptions) =>
request<SessionMcpAddOutput>(
{
method: "PUT",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/mcp/${encodeURIComponent(input.server)}`,
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
remove: (input: SessionMcpRemoveInput, requestOptions?: RequestOptions) =>
request<SessionMcpRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/mcp/${encodeURIComponent(input.server)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
generate: (input: SessionGenerateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGenerateOutput }>(
{
@@ -1055,6 +1087,7 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
query: { message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
@@ -1392,6 +1425,29 @@ export function make(options: ClientOptions) {
},
},
credential: {
list: (requestOptions?: RequestOptions) =>
request<{ readonly data: CredentialListOutput }>(
{ method: "GET", path: `/api/credential`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
).then((value) => value.data),
create: (input: CredentialCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: CredentialCreateOutput }>(
{
method: "POST",
path: `/api/credential`,
body: {
id: input["id"],
integrationID: input["integrationID"],
label: input["label"],
value: input["value"],
activate: input["activate"],
},
successStatus: 200,
declaredStatuses: [400, 401, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialUpdateOutput>(
{
+282 -24
View File
@@ -133,7 +133,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = { type: string; message: string; status?: number; response?: { body: string } }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -293,6 +293,15 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type CredentialOAuth = {
type: "oauth"
methodID: string
refresh: string
access: string
expires: number
metadata?: { [x: string]: JsonValue }
}
export type ProjectVcs = string
export type ProjectIcon = { url?: string; override?: string; color?: string }
@@ -1656,7 +1665,7 @@ export type FormMultiselectField1 = {
default?: Array<string>
}
export type FormAnswer1 = { [x: string]: FormValue1 }
export type FormAnswer2 = { [x: string]: FormValue1 }
export type SessionStatusUpdated = {
id: string
@@ -1888,7 +1897,17 @@ export type FormField =
| FormMultiselectField
| FormExternalField
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type FormState =
| { status: "pending" }
| { status: "answered"; answer: FormAnswer }
| { status: "cancelled"; message?: string }
export type CredentialKey = {
type: "key"
key: string
metadata?: { [x: string]: JsonValue }
configuration?: FormAnswer
}
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
@@ -1906,7 +1925,7 @@ export type FormReplied = {
metadata?: { [x: string]: any }
type: "form.replied"
location?: LocationRef
data: { id: string; sessionID: string; answer: FormAnswer1 }
data: { id: string; sessionID: string; answer: FormAnswer2 }
}
export type ReferenceInfo = {
@@ -2225,6 +2244,8 @@ export type SessionMessageAssistantTool1 = {
export type FormFields = [FormField, ...Array<FormField>]
export type CredentialValue = CredentialOAuth | CredentialKey
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
@@ -2279,6 +2300,14 @@ export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string;
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
export type CredentialEntry = {
id: string
integrationID: string
label: string
active: boolean
value: CredentialValue
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
export type SessionMessageInfo =
@@ -2589,6 +2618,14 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
"_tag" in value &&
value["_tag"] === "InstructionEntryValueTooLargeError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
readonly message: string
}
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -2643,14 +2680,6 @@ export type IntegrationMethodNotFoundError = {
export const isIntegrationMethodNotFoundError = (value: unknown): value is IntegrationMethodNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationMethodNotFoundError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
readonly message: string
}
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type ProjectNotFoundError = {
readonly _tag: "ProjectNotFoundError"
readonly projectID: string
@@ -3183,7 +3212,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3220,11 +3254,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3276,7 +3320,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3500,7 +3549,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3537,11 +3591,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3593,7 +3657,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3817,7 +3886,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3854,11 +3928,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3910,7 +3994,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -4467,6 +4556,52 @@ export type SessionInstructionsEntryRemoveInput = {
export type SessionInstructionsEntryRemoveOutput = void
export type SessionMcpAddInput = {
readonly sessionID: { readonly sessionID: string; readonly server: string }["sessionID"]
readonly server: { readonly sessionID: string; readonly server: string }["server"]
readonly config: {
readonly config:
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string
readonly environment?: { readonly [x: string]: string }
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
readonly protocol?: "legacy" | "auto" | "2026-07-28"
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string }
readonly oauth?:
| {
readonly client_id?: string
readonly client_secret?: string
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
readonly auth_server_metadata_url?: string
}
| false
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
readonly protocol?: "legacy" | "auto" | "2026-07-28"
}
}["config"]
}
export type SessionMcpAddOutput = void
export type SessionMcpRemoveInput = {
readonly sessionID: { readonly sessionID: string; readonly server: string }["sessionID"]
readonly server: { readonly sessionID: string; readonly server: string }["server"]
}
export type SessionMcpRemoveOutput = void
export type SessionGenerateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly prompt: { readonly prompt: string }["prompt"]
@@ -5374,6 +5509,7 @@ export type SessionFormReplyOutput = void
export type SessionFormCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly formID: string }["sessionID"]
readonly formID: { readonly sessionID: string; readonly formID: string }["formID"]
readonly message?: { readonly message?: string | undefined }["message"]
}
export type SessionFormCancelOutput = void
@@ -5690,6 +5826,128 @@ export type McpResourceCatalogInput = {
export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpResourceCatalog }
export type CredentialListOutput = { data: Array<CredentialEntry> }["data"]
export type CredentialCreateInput = {
readonly id?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["id"]
readonly integrationID: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["integrationID"]
readonly label?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["label"]
readonly value: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["value"]
readonly activate?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["activate"]
}
export type CredentialCreateOutput = { data: CredentialEntry }["data"]
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly label: { readonly label: string }["label"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.19",
"version": "2.0.20",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.19",
"version": "2.0.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -129,6 +129,7 @@
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
+32 -13
View File
@@ -38,11 +38,16 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Creates a credential for an integration and returns the new record. */
/**
* Creates a credential for an integration and returns the new record. The new credential becomes the
* integration's selection unless `activate` is false and the integration already has a credential.
*/
readonly create: (input: {
readonly id?: ID
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
readonly activate?: boolean
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
@@ -99,19 +104,30 @@ const layer = Layer.effect(
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({
id: ID.create(),
id: input.id ?? ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
value: input.value,
})
yield* db
const activated = yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(CredentialTable)
.set({ active: false })
const current = yield* tx
.select({ id: CredentialTable.id, active: CredentialTable.active })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
const activate = input.activate !== false || !current
if (activate)
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
// Legacy rows have no active flag and the newest one is selected, so pin it before inserting a newer row.
if (!activate && current && !current.active)
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, current.id)).run()
yield* tx
.insert(CredentialTable)
.values({
@@ -119,9 +135,10 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
active: activate,
})
.run()
return activate
}),
)
.pipe(
@@ -138,13 +155,15 @@ const layer = Layer.effect(
credentialID: credential.id,
integrationID: credential.integrationID,
type: credential.value.type,
active: activated,
})
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
if (activated)
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
+23 -3
View File
@@ -5,6 +5,8 @@ import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#s
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode/util/global"
import { closeSync, existsSync, openSync } from "node:fs"
import { chmod } from "node:fs/promises"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -70,13 +72,31 @@ export function layer(options: Options = { path: ":memory:" }) {
Layer.provide(sqliteLayer({ filename })),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
if (filename === ":memory:") return provide(filename)
const file = isAbsolute(filename) ? filename : join((yield* Global.Service).data, filename)
yield* Effect.promise(() => restrictToOwner(file))
return provide(file)
}),
)
}
// SQLite creates new sidecars with the database's mode, but does not tighten existing sidecars.
// Windows relies on the user profile directory's inherited ACLs instead of POSIX modes.
async function restrictToOwner(filename: string) {
if (process.platform === "win32") return
// Opening and closing an existing database can release another connection's POSIX locks.
// Create missing files synchronously so another fiber cannot open one before it is restricted.
if (!existsSync(filename)) closeSync(openSync(filename, "a", 0o600))
await chmod(filename, 0o600)
await Promise.all(
[`${filename}-wal`, `${filename}-shm`].map((file) =>
chmod(file, 0o600).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error
}),
),
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required

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