Compare commits

..
8 Commits
341 changed files with 5283 additions and 6585 deletions
-1
View File
@@ -184,7 +184,6 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
+83 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -87,11 +87,13 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -99,6 +101,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -111,7 +114,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.7",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +218,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +254,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +281,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +298,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +322,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +342,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -407,12 +410,14 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"lighthouse": "13.4.1",
},
"devDependencies": {
@@ -452,11 +457,12 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"msgpackr-extract": "3.0.4",
},
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -493,7 +499,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +515,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -528,7 +534,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -541,7 +547,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -555,7 +561,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -570,7 +576,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -609,7 +615,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -639,7 +645,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -654,7 +660,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -678,7 +684,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -699,7 +705,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -721,7 +727,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -756,7 +762,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -776,7 +782,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -810,7 +816,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -829,7 +835,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -875,7 +881,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -889,7 +895,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -924,7 +930,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -959,7 +965,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -992,7 +998,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1033,7 +1039,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.8",
"version": "2.0.7",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3339,6 +3345,8 @@
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
@@ -3633,6 +3641,8 @@
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
@@ -3771,6 +3781,8 @@
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -3897,12 +3909,16 @@
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="],
"electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
@@ -4447,6 +4463,8 @@
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
@@ -4457,7 +4475,7 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
@@ -4719,6 +4737,8 @@
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
@@ -5473,6 +5493,8 @@
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -5597,6 +5619,8 @@
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
@@ -6423,6 +6447,10 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
@@ -6463,6 +6491,8 @@
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"electron-updater/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -6493,6 +6523,8 @@
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
@@ -6895,12 +6927,18 @@
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
@@ -6977,6 +7015,8 @@
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
@@ -7355,6 +7395,8 @@
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"conf/dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
@@ -7379,8 +7421,6 @@
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
@@ -7529,6 +7569,8 @@
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
@@ -7537,6 +7579,8 @@
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.8",
"version": "2.0.7",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -12,7 +12,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.7",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
thinkingBudget: Schema.optional(Schema.Int),
preserveThinking: Schema.optional(Schema.Boolean),
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
}),
),
toolStream: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
repetitionPenalty: Schema.optional(Schema.Number),
responseFormat: Schema.optional(
Schema.Struct({
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const Options = Schema.Struct({
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
enableThinking: Schema.optional(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
store: OpenResponsesOptions.Options.fields.store,
previousResponseId: Schema.optional(Schema.String),
conversation: Schema.optional(Schema.String),
})
+167 -79
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -21,12 +21,11 @@ import {
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -53,10 +52,57 @@ const SSE_EVENTS = new Set([
])
export const framing = Framing.sseEvents(SSE_EVENTS)
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
export type ThinkingInput = typeof Thinking.Encoded
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export type ThinkingBlockBinding = {
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
}
export type ThinkingInput =
| {
readonly type: "adaptive"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
}
| {
readonly type: "disabled"
}
| ({
readonly type: "enabled"
readonly display?: "summarized" | "omitted"
readonly block_binding?: ThinkingBlockBinding
} & (
| { readonly budgetTokens: number; readonly budget_tokens?: number }
| { readonly budgetTokens?: number; readonly budget_tokens: number }
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
readonly service_tier?: "auto" | "standard_only"
readonly serviceTier?: "auto" | "standard_only"
// SDK Metadata:2649 {user_id?: string | null}
readonly metadata?: { readonly user_id?: string | null }
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
readonly container?:
| string
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
readonly inference_geo?: string | null
readonly inferenceGeo?: string | null
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
readonly output_config?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
readonly outputConfig?: {
readonly effort?: string | null
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
}
}
export type ProviderOptionsInput = OptionsInput
export const ContextManagement = Schema.Struct({
@@ -83,7 +129,6 @@ const AnthropicCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
const AnthropicTextBlock = Schema.Struct({
type: Schema.tag("text"),
@@ -272,21 +317,25 @@ const AnthropicToolChoice = Schema.Union([
])
const AnthropicThinkingBlockBinding = Schema.Struct({
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
prefix_mismatch_behavior: Schema.optional(Schema.String),
})
const AnthropicThinkingFields = {
display: Schema.optional(knownString<"summarized" | "omitted">()),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}
const AnthropicThinkingEnabled = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
...AnthropicThinkingFields,
})
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
type AnthropicThinking = typeof AnthropicThinking.Type
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
@@ -311,53 +360,6 @@ const AnthropicContainer = Schema.Union([
}),
])
// =============================================================================
// Provider Options
// =============================================================================
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
// keys are disjoint per variant so the input type requires exactly one and the
// transform can narrow on it. Decoding straight to the wire block keeps the
// alias out of the rest of the file.
const ThinkingEnabledInput = Schema.Union([
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
]).pipe(
Schema.decodeTo(AnthropicThinkingEnabled, {
decode: SchemaGetter.transform((input) => ({
type: "enabled" as const,
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
display: input.display,
block_binding: input.block_binding,
})),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
const OutputConfigInput = Schema.Struct({
effort: optionalNull(Schema.String),
format: optionalNull(AnthropicJsonOutputFormat),
})
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
const Options = Schema.Struct({
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
contextManagement: Schema.optional(ContextManagement),
thinking: Schema.optional(Thinking),
effort: Schema.optional(Schema.String),
service_tier: Schema.optional(AnthropicServiceTier),
serviceTier: Schema.optional(AnthropicServiceTier),
metadata: Schema.optional(AnthropicMetadata),
container: Schema.optional(AnthropicContainer),
inference_geo: optionalNull(Schema.String),
inferenceGeo: optionalNull(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
cacheControl: Schema.optional(AnthropicCacheControl),
output_config: Schema.optional(OutputConfigInput),
outputConfig: Schema.optional(OutputConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const AnthropicBodyFields = {
context_management: Schema.optional(
Schema.Struct({
@@ -389,7 +391,7 @@ const AnthropicBodyFields = {
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(AnthropicMetadata),
service_tier: Schema.optional(AnthropicServiceTier),
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
}
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -999,6 +1001,64 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
const input = request.providerOptions as Record<string, unknown> | undefined
const rawServiceTier =
(input as Record<string, unknown> | undefined)?.service_tier ??
(input as Record<string, unknown> | undefined)?.serviceTier
const service_tier =
rawServiceTier === "auto" || rawServiceTier === "standard_only"
? (rawServiceTier as "auto" | "standard_only")
: undefined
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
const metadata =
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
? { user_id: rawMetadata.user_id as string | null }
: undefined
const container =
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
? ((input as Record<string, unknown>).container as
| string
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
: undefined
const rawInferenceGeo =
(input as Record<string, unknown> | undefined)?.inference_geo ??
(input as Record<string, unknown> | undefined)?.inferenceGeo
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
const rawCacheControl =
(input as Record<string, unknown> | undefined)?.cache_control ??
(input as Record<string, unknown> | undefined)?.cacheControl
const cache_control =
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
: undefined
const rawOutputConfig =
(input as Record<string, unknown> | undefined)?.output_config ??
(input as Record<string, unknown> | undefined)?.outputConfig
const outputConfigEffort =
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
? ((input as Record<string, unknown>).effort as string)
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
? (rawOutputConfig.effort as string)
: undefined
const outputConfigFormat =
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
service_tier,
metadata,
container,
inference_geo,
cache_control,
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
@@ -1037,12 +1097,35 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
if (!ProviderShared.isRecord(input)) return undefined
if (input.type === "disabled") return { type: "disabled" as const }
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
const block_binding = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
)(input.block_binding)
const display =
input.display === "summarized" || input.display === "omitted"
? (input.display as "summarized" | "omitted")
: undefined
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
const budget =
typeof input.budgetTokens === "number"
? input.budgetTokens
: typeof input.budget_tokens === "number"
? input.budget_tokens
: undefined
if (budget === undefined)
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
})
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
const outputConfig = options.output_config ?? options.outputConfig
const format = outputConfig?.format ?? undefined
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
@@ -1078,7 +1161,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const body = {
model: request.model.id,
system,
@@ -1091,14 +1179,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: options.thinking,
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
cache_control: options.cache_control,
container: options.container,
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
inference_geo: options.inference_geo,
metadata: options.metadata,
service_tier: options.service_tier ?? options.serviceTier,
service_tier: options.service_tier,
}
if (!management) return body
return {
+68 -45
View File
@@ -14,13 +14,12 @@ import {
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -51,8 +50,35 @@ const omitsFunctionCallIds = (modelID: string) => {
return match !== null && Number(match[1]) < 3
}
/** Caller-facing provider options; unknown keys are accepted and ignored. */
export type OptionsInput = ProviderOptions & typeof Options.Encoded
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
readonly safetySettings?: ReadonlyArray<{
readonly category:
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
| (string & {})
readonly threshold:
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
| (string & {})
}>
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
readonly thinkingConfig?: {
readonly thinkingBudget?: number
readonly includeThoughts?: boolean
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
}
}
export type ProviderOptionsInput = OptionsInput
// =============================================================================
@@ -135,50 +161,17 @@ const GeminiToolConfig = Schema.Struct({
}),
})
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
const GeminiThinkingConfig = Schema.Struct({
thinkingBudget: Schema.optional(Schema.Number),
includeThoughts: Schema.optional(Schema.Boolean),
thinkingLevel: Schema.optional(GeminiThinkingLevel),
thinkingLevel: Schema.optional(Schema.String),
})
const GeminiSafetySetting = Schema.Struct({
category: knownString<
| "HARM_CATEGORY_UNSPECIFIED"
| "HARM_CATEGORY_HATE_SPEECH"
| "HARM_CATEGORY_DANGEROUS_CONTENT"
| "HARM_CATEGORY_HARASSMENT"
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
| "HARM_CATEGORY_CIVIC_INTEGRITY"
>(),
threshold: knownString<
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
| "BLOCK_LOW_AND_ABOVE"
| "BLOCK_MEDIUM_AND_ABOVE"
| "BLOCK_ONLY_HIGH"
| "BLOCK_NONE"
| "OFF"
>(),
category: Schema.String,
threshold: Schema.String,
})
// =============================================================================
// Provider Options
// =============================================================================
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
// object that omits `includeThoughts` asks for thoughts.
const GeminiThinkingConfigInput = Schema.Struct({
thinkingBudget: lenient(Schema.Number),
includeThoughts: lenient(Schema.Boolean),
thinkingLevel: lenient(GeminiThinkingLevel),
})
const Options = Schema.Struct({
cachedContent: lenient(Schema.String),
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
thinkingConfig: lenient(GeminiThinkingConfigInput),
})
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -438,11 +431,44 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
return contents
})
const resolveOptions = (request: LLMRequest) => {
const input = request.providerOptions
const value = input?.thinkingConfig
const thinkingConfig = {
thinkingBudget:
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
includeThoughts:
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
? value.includeThoughts
: ProviderShared.isRecord(value)
? true
: undefined,
thinkingLevel:
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
}
return {
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
safetySettings: mapSafetySettings(input?.safetySettings),
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
}
}
function mapSafetySettings(value: unknown) {
if (!Array.isArray(value)) return undefined
const settings = value.flatMap((item) =>
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
? [{ category: item.category, threshold: item.threshold }]
: [],
)
return settings
}
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const flattened = ProviderShared.flattenToolRequest(request)
const hasTools = flattened.tools.length > 0
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const options = resolveOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = {
maxOutputTokens: generation?.maxTokens,
@@ -453,10 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
thinkingConfig: options.thinkingConfig,
}
return {
+1 -11
View File
@@ -1,6 +1,6 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode/schema/tool"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
@@ -29,16 +29,6 @@ const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
expected: "string",
})
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import { lenient } from "../shared.js"
export { ReasoningEffort, ReasoningEfforts }
@@ -50,22 +49,21 @@ export const StreamOptions = Schema.Struct({
includeObfuscation: Schema.optional(Schema.Boolean),
})
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
export const Options = Schema.Struct({
store: lenient(Schema.Boolean),
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: lenient(Schema.String),
streamOptions: lenient(StreamOptions),
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: lenient(ReasoningEffort),
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
include: lenient(Schema.Array(ResponseIncludableSchema)),
textVerbosity: lenient(TextVerbositySchema),
serviceTier: lenient(ServiceTierSchema),
truncation: lenient(TruncationSchema),
allowedTools: lenient(AllowedTools),
maxToolCalls: lenient(Schema.Int),
parallelToolCalls: lenient(Schema.Boolean),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
streamOptions: Schema.optional(StreamOptions),
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
reasoningEffort: Schema.optional(ReasoningEffort),
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
textVerbosity: Schema.optional(TextVerbositySchema),
serviceTier: Schema.optional(ServiceTierSchema),
truncation: Schema.optional(TruncationSchema),
allowedTools: Schema.optional(AllowedTools),
maxToolCalls: Schema.optional(Schema.Int),
parallelToolCalls: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -73,10 +71,11 @@ export type Resolved = Omit<Options, "allowedTools"> & {
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
}
const decodeOptions = Schema.decodeUnknownSync(Options)
const decodeOptions = Schema.decodeUnknownOption(Options)
export const resolve = (request: LLMRequest): Resolved => {
const input = decodeOptions(request.providerOptions ?? {})
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
if (!input) return {}
return {
...input,
include: input.include?.length ? input.include : undefined,
@@ -4,22 +4,6 @@ import { Anthropic } from "../../src/providers.js"
const model = Anthropic.provider.model("claude-sonnet-4-5")
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
serviceTier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
})
LLM.request({
model,
prompt: "Hello",
providerOptions: {
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
},
})
LLM.request({
model,
@@ -166,89 +166,7 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("budgetTokens")
}),
)
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
Effect.gen(function* () {
const snake = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
},
}),
)
const camel = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
serviceTier: "standard_only",
container: "container_2",
inferenceGeo: "eu",
cacheControl: { type: "ephemeral" },
outputConfig: { effort: "low" },
},
}),
)
expect(snake.body).toMatchObject({
service_tier: "auto",
metadata: { user_id: "user_1" },
container: { id: "container_1" },
inference_geo: "us",
cache_control: { type: "ephemeral", ttl: "1h" },
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
})
expect(camel.body).toMatchObject({
service_tier: "standard_only",
container: "container_2",
inference_geo: "eu",
cache_control: { type: "ephemeral" },
output_config: { effort: "low" },
})
}),
)
it.effect("forwards unknown values for pass-through string enums", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
},
}),
)
expect(prepared.body).toMatchObject({
service_tier: "future-tier",
thinking: { type: "adaptive", display: "future-display" },
})
}),
)
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
const malformed = [
{ service_tier: 1 },
{ metadata: { user_id: 42 } },
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
{ output_config: { format: { type: "text" } } },
{ thinking: { type: "automatic" } },
]
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
)
expect(prepared.body).not.toHaveProperty("unknownOption")
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
}),
)
@@ -248,21 +248,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
}),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,30 +1945,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops a malformed provider option without discarding its siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "hi",
providerOptions: {
topLogprobs: 25,
metadata: { tenant: 7 },
reasoningEffort: "high",
serviceTier: "priority",
maxToolCalls: 4,
},
}),
)
expect(prepared.body.top_logprobs).toBeUndefined()
expect(prepared.body.metadata).toBeUndefined()
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.service_tier).toBe("priority")
expect(prepared.body.max_tool_calls).toBe(4)
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the hosted app hands HTTP-only pairing links over to the server's own web UI", async ({ page, baseURL }) => {
const dev = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const hosted = "https://app.opencode.ai"
const lan = "http://192.168.1.20:49374"
// Serve the dev build under the hosted HTTPS origin so mixed-content rules apply to the page.
await page.route(`${hosted}/**`, async (route) => {
const response = await page.request.fetch(route.request().url().replace(hosted, dev))
await route.fulfill({ response })
})
await page.route(`${lan}/**`, (route) =>
route.fulfill({ status: 200, contentType: "text/html", body: "<title>served</title>" }),
)
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
const info = { urls: [lan], username: "opencode", password: "lan-secret" }
const fragment = Buffer.from(JSON.stringify(info)).toString("base64url")
await page.goto(`${hosted}/connect#${fragment}`)
await expect(page.getByRole("heading", { name: "This server is on a local network" })).toBeVisible()
await expect(page.getByText(lan, { exact: true })).toBeVisible()
expect(requests).toEqual([])
await page.getByRole("button", { name: "Open on local network" }).click()
await expect(page).toHaveURL(`${lan}/connect#${fragment}`)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.8",
"version": "2.0.7",
"description": "",
"type": "module",
"exports": {
@@ -46,6 +46,7 @@
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "4.3.3",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"diff": "catalog:",
@@ -87,10 +88,12 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3"
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
}
}
+24 -18
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -102,34 +102,40 @@ export function AppInterface(props: {
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
serversPending?: boolean
router?: Component<BaseRouterProps>
}) {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
return (
<ServersProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
pending={props.serversPending}
>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
+12 -17
View File
@@ -1,10 +1,9 @@
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useI18n } from "@opencode/ui/context/i18n"
import { Button } from "@opencode/ui/button"
@@ -546,21 +545,17 @@ export function ComposerAttachments(props: {
</AttachmentCard>
}
>
{(image) => {
// Restored drafts and history carry image ids only; bytes load when shown.
const [url] = createResource(() => image().blob, resolveBlobUrl)
return (
<>
<img
src={url() ?? ""}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)
}}
{(image) => (
<>
<img
src={image().blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)}
</Show>
</Tooltip>
<button
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { Schema } from "effect"
import { PromptHistoryState } from "../schema"
import { Persistence } from "@/runtime/persistence/schema"
@@ -36,24 +36,6 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
const image: Prompt = [
{ type: "text", content: "look", start: 0, end: 4 },
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
]
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
expect(entries).toHaveLength(2)
const untouched = removeHistoryEntry(entries, text("never sent"))
expect(untouched).toBe(entries)
const withoutComments = removeHistoryEntry(entries, image)
expect(withoutComments).toBe(entries)
const removed = removeHistoryEntry(entries, image, [comment("c1")])
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
@@ -48,18 +48,6 @@ export function prependHistoryEntry(
return [entry, ...entries].slice(0, max)
}
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
export function removeHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
) {
const entry = { prompt, comments } satisfies PromptHistoryEntry
const next = entries.filter((item) => !isPromptEqual(item, entry))
return next.length === entries.length ? entries : next
}
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
return (
commentA.path === commentB.path &&
@@ -4,7 +4,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
prependHistoryEntry,
removeHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
@@ -14,7 +13,6 @@ import { PromptHistoryState } from "../schema"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
}
type PromptHistoryState = typeof PromptHistoryState.Type
@@ -34,13 +32,6 @@ function createComposerHistoryStore(
if (next === current.entries) return
setCurrent("entries", next)
},
remove(prompt, mode, comments) {
const current = mode === "shell" ? shell : normal
const setCurrent = mode === "shell" ? setShell : setNormal
const next = removeHistoryEntry(current.entries, prompt, comments)
if (next === current.entries) return
setCurrent("entries", next)
},
}
}
@@ -65,12 +56,5 @@ export function createComposerHistory() {
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.remove(saved, mode, metadata))
},
}
}
+1 -5
View File
@@ -11,7 +11,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { useData, useServer } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
@@ -264,7 +263,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
@@ -324,9 +322,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
openAttachment: (attachment) => {
if (attachment.type !== "image") return
void resolveBlobUrl(attachment.blob).then((src) => {
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
})
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
},
openContext(key) {
const item = controller.contextItem(key)
+2 -6
View File
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
).toEqual(value)
})
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
@@ -149,16 +149,12 @@ describe("composer persistence schemas", () => {
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(6)
expect(value.prompt).toHaveLength(3)
expect(value.prompt[0]).toEqual({
...image,
sourcePath: "/image.png",
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
})
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
const encoded = Schema.encodeSync(ComposerStore)(value)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(
+3 -4
View File
@@ -61,12 +61,11 @@ const ImageFields = {
}
const Image = Persistence.struct({
...ImageFields,
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
})
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
-9
View File
@@ -88,15 +88,6 @@ describe("prompt state initialization", () => {
start: 5,
end: 12,
},
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
{
type: "image",
id: "missing-blob",
filename: "missing.png",
mime: "image/png",
blob: { id: "content-hash-without-a-url", url: "" },
},
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
{
type: "image",
id: "legacy",
+7 -8
View File
@@ -53,7 +53,6 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
) {
return createComposerSubmit({
adapter,
@@ -61,9 +60,7 @@ function submitInput(
commands,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
removeFromHistory: (prompt) =>
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
addToHistory() {},
resetHistory() {},
setMode() {},
closePopover() {},
@@ -536,8 +533,12 @@ describe("Composer submission", () => {
missingSelection() {},
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
}
const history: string[] = []
const submission = submitInput(adapter, notify, "normal", () => [], history)
const submission = submitInput(
adapter,
notify,
"normal",
() => [],
)
await submission.submit(new Event("submit"))
await first.promise
@@ -548,8 +549,6 @@ describe("Composer submission", () => {
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: text }])
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
})
test("forwards structured mentions to custom commands", async () => {
+3 -16
View File
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
@@ -32,7 +32,6 @@ type ComposerSubmitInput = {
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
@@ -60,22 +59,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
images: await Promise.all(
read.images.map(async (image) => ({
...image,
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -251,8 +240,6 @@ function restoreSubmission(
) {
const restored = submission.restore()
if (!restored) return false
// The prompt is back in the composer; its history entry would only keep attachments referenced.
input.removeFromHistory(value.prompt, value.mode, comments)
restored.target.set(restored.prompt, promptLength(restored.prompt))
restored.target.mode.set(value.mode)
restored.target.context.replaceComments(
+13 -12
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { Button } from "@opencode/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
@@ -326,19 +327,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.location.directory)}:${record.session.id}`
}
// Calendar day in the local time zone, comparable as a number.
function localDay(date: Date) {
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = new Date()
const today = localDay(now)
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
const todaySessions = records.filter((record) => day(record) === today)
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
+2 -14
View File
@@ -7,7 +7,7 @@ import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider, useServer } from "@/runtime/server/current"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
@@ -30,23 +30,11 @@ export function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const servers = useServers()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show
when={conn()}
keyed
fallback={
// The shell is up before the local service has connected; hold the panel's place.
<Show when={servers.pending}>
<SessionRouteFrame padded>
<SessionPanelFrame raised />
</SessionRouteFrame>
</Show>
}
>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ResolvedDraftContent draft={props.draft} />
+16 -12
View File
@@ -1,4 +1,5 @@
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode/ui/context"
import { useProviders } from "@/providers/catalog/providers"
@@ -8,8 +9,6 @@ export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
const RECENT_LIMIT = 5
// luxon's diffNow().as("months") used an average month; keep the same window.
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
@@ -30,23 +29,27 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
),
)
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
const release = createMemo(
() =>
new Map(
available().map(
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
),
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => {
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
return Math.abs(Date.now() - released) < sixMonths
}),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -98,8 +101,9 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
// Models without a parseable release date stay visible.
return !Number.isFinite(release().get(key) ?? NaN)
const date = release().get(key)
if (!date?.isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
+38
View File
@@ -440,6 +440,44 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"server.connect.local.title": "This server is on a local network",
"server.connect.local.description":
"This page can't reach HTTP servers on your local network. Open the server's own web interface to continue on this device.",
"server.connect.local.open": "Open on local network",
"server.connect.local.loopback.title": "This server only listens on localhost",
"server.connect.local.loopback.description":
"Other devices can't reach a server that only listens on localhost. Opening it will only work on the computer running OpenCode.",
"server.connect.local.loopback.open": "Open on this computer",
"server.connect.local.loopback.fix": "Run this command on your computer, then pair again.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.qr.open": "Show details",
"pair.tailscale.enable": "Enable Tailscale",
"pair.tailscale.serve": "Tailscale Serve",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.loading": "Loading connection details…",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.urls": "URLs",
"pair.username": "Username",
"pair.password": "Password",
"pair.error": "Could not update pairing details. Try again.",
"pair.tailscale.open": "Share with Tailscale",
"pair.tailscale.opening": "Enabling Tailscale Serve…",
"pair.tailscale.title": "Tailscale",
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
"pair.tailscale.checking": "Checking Tailscale Serve…",
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
"pair.tailscale.disable": "Disable",
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
function memoryDriver() {
const documents = new Map<string, string>()
@@ -287,34 +287,15 @@ describe("draft store image retention", () => {
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references without fetching their bytes", async () => {
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const reads: string[] = []
const getBlob = memory.driver.getBlob
memory.driver.getBlob = (id) => {
reads.push(id)
return getBlob(id)
}
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
expect(loaded).toEqual({ id })
expect(reads).toEqual([])
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
const url = (await resolveBlobUrl(loaded))!
expect(url.startsWith("blob:")).toBe(true)
expect(reads).toEqual([id])
expect(await resolveBlobUrl(loaded)).toBe(url)
expect(reads).toEqual([id])
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
fresh()
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
})
})
+4 -21
View File
@@ -42,18 +42,6 @@ const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
// that renders or sends an image resolves its URL through here, so a history full of large
// attachments costs nothing at startup.
let loader: ((id: string) => Promise<string | undefined>) | undefined
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
export function resolveBlobUrl(blob: { id: string; url?: string }) {
if (blob.url) return Promise.resolve(blob.url)
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
if (existing) return Promise.resolve(existing.url)
return loader?.(blob.id) ?? Promise.resolve(undefined)
}
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
@@ -132,7 +120,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = retained.get(id)
if (existing) return Promise.resolve(existing.url)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
@@ -142,7 +130,6 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
loading.set(id, next)
return next
}
loader = loadBlobUrl
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob, grace) }
@@ -234,11 +221,9 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
if (ref.kind === "text" && Array.isArray(ref.ids)) {
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
}
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
// only an image already pinned in this page gets its URL back immediately.
if (typeof ref.id === "string") {
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
const url = await loadBlobUrl(ref.id)
if (url) return { ...item, blob: { id: ref.id, url } }
}
}
return Object.fromEntries(
@@ -438,9 +423,7 @@ function referenced(json: string) {
export async function blobDataUrl(blob: BlobReference, mime: string) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const url = kept ? undefined : await resolveBlobUrl(blob)
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -22,6 +22,12 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -101,6 +107,10 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -124,6 +134,15 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
tailscaleAvailable(): Promise<boolean>
tailscaleStatus(): Promise<PairingInfo | null>
openTailscale(): Promise<PairingInfo>
disableTailscale(): Promise<void>
}
}
export type Platform = PlatformBase &
@@ -199,9 +199,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
// The host is still discovering connections (desktop: the local service, WSL, SSH). The shell
// renders meanwhile; nothing that depends on the list being complete may act on it yet.
pending?: boolean
}) => {
const [store, setStore, _] = persisted(
{
@@ -261,9 +258,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get visible() {
return visibleServers()
},
get pending() {
return props.pending ?? false
},
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
+5 -1
View File
@@ -7,6 +7,10 @@ export function isMixedContent(page: string, address: string) {
const url = new URL(normalized)
if (url.protocol !== "http:") return false
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
return !isLoopback(url)
}
export function isLoopback(url: URL) {
const host = url.hostname.replace(/\.$/, "")
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host)
}
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://computer.tailnet.ts.net:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
const info = {
urls: ["http://192.168.1.2:4096"],
username: "opencode" as const,
password: "a+b & café",
}
const url = new URL(pairingUrl(info, "https://example.com"))
expect(url.origin).toBe("https://example.com")
expect(url.pathname).toBe("/connect")
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
expect(decodePairingUrl(url.search)).toEqual({
urls: ["http://192.168.1.2:4096"],
password: "a+b & café",
})
})
test("defaults to the hosted app for desktop pairing", () => {
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
"https://app.opencode.ai",
)
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
const decoded = { urls: info.urls, password: info.password }
test("decodes raw JSON, desktop query links, and the CLI fragment link", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual(decoded)
expect(decodePairingScan(pairingUrl(info, "http://192.168.1.2:49374"))).toEqual(decoded)
const encoded = Buffer.from(JSON.stringify(info)).toString("base64url")
expect(decodePairingScan(`https://app.opencode.ai/connect#${encoded}`)).toEqual(decoded)
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
+39 -3
View File
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.Array(Schema.String),
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -19,10 +19,46 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string) {
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
const urls = [
...new Set(
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(
value: { urls?: readonly string[]; username: "opencode"; password: string },
host = "https://app.opencode.ai",
) {
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
}
export function decodePairingScan(value: string) {
const url = URL.parse(value)
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
}
+3 -3
View File
@@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
import { decodePairingScan } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
const language = useLanguage()
@@ -22,7 +22,7 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingCode(result.data)
const pairing = decodePairingScan(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
+47 -3
View File
@@ -9,19 +9,28 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import type { decodePairingCode } from "./pairing"
import { isLoopback, isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen() {
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -42,6 +51,7 @@ export function ConnectServerScreen() {
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -164,3 +174,37 @@ export function ConnectServerScreen() {
</main>
)
}
export function ConnectLocalScreen(props: { urls: readonly string[] }) {
const language = useLanguage()
const target = props.urls[0]
const loopback = isLoopback(new URL(target))
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
<div class="server-connect-brand" role="img" aria-label="OpenCode">
<Wordmark />
</div>
<header>
<h1 id="server-connect-title">
{language.t(loopback ? "server.connect.local.loopback.title" : "server.connect.local.title")}
</h1>
<p>{language.t(loopback ? "server.connect.local.loopback.description" : "server.connect.local.description")}</p>
</header>
<Button
variant="contrast"
size="large"
onClick={() => location.assign(`${new URL("/connect", target)}${location.search}${location.hash}`)}
>
{language.t(loopback ? "server.connect.local.loopback.open" : "server.connect.local.open")}
</Button>
<footer>
<Show when={loopback}>
<p>{language.t("server.connect.local.loopback.fix")}</p>
</Show>
<code dir="ltr">{loopback ? "opencode service set hostname 0.0.0.0" : target}</code>
</footer>
</div>
</main>
)
}
@@ -1,12 +1,6 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
const dateTime = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
})
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
@@ -20,7 +14,7 @@ export function createSessionContextFormatter(locale: string) {
},
time(value: number | undefined) {
if (!value) return "—"
return dateTime.format(value)
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
+2
View File
@@ -7,6 +7,7 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -22,6 +23,7 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -0,0 +1,303 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
const tailscale = useQuery(() => ({
queryKey: ["pairing", "tailscale-available"],
queryFn: pair.tailscaleAvailable,
}))
const tailscaleStatus = useQuery(() => ({
queryKey: ["pairing", "tailscale-status"],
queryFn: pair.tailscaleStatus,
enabled: tailscale.isSuccess && tailscale.data === true,
}))
const tailscaleServe = useMutation(() => ({
mutationFn: pair.openTailscale,
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
}))
const tailscaleDisable = useMutation(() => ({
mutationFn: pair.disableTailscale,
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
}))
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
<Show when={tailscale.isSuccess && tailscale.data === true}>
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
<Show
when={tailscaleInfo()}
fallback={
<>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
<Button
variant="neutral"
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
aria-busy={tailscaleServe.isPending}
onClick={() => tailscaleServe.mutate()}
>
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<g opacity="0.3">
<circle cx="3" cy="3" r="3" />
<circle cx="12" cy="3" r="3" />
<circle cx="21" cy="3" r="3" />
<circle cx="3" cy="21" r="3" />
<circle cx="21" cy="21" r="3" />
</g>
<circle cx="3" cy="12" r="3" />
<circle cx="12" cy="12" r="3" />
<circle cx="21" cy="12" r="3" />
<circle cx="12" cy="21" r="3" />
</svg>
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
</Button>
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
{language.t("pair.tailscale.description")}
</span>
</div>
</SettingsList>
</>
}
>
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
<SettingsList>
<SettingsRow
title={language.t("pair.tailscale.serve")}
description={language.t("pair.tailscale.description")}
>
<div class="flex flex-col items-end gap-2">
<Button
variant="neutral"
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("pair.tailscale.title")}
info={tailscaleInfo()}
host={tailscaleInfo()?.urls[0]}
/>
))
}
>
{language.t("pair.qr.open")}
</Button>
<Button
variant="neutral"
disabled={
!localInfo() ||
tailscaleStatus.isPending ||
tailscaleServe.isPending ||
tailscaleDisable.isPending
}
onClick={() => tailscaleDisable.mutate()}
>
{language.t("pair.tailscale.disable")}
</Button>
</div>
</SettingsRow>
</SettingsList>
</Show>
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.tailscale.error")}
</p>
</Show>
</section>
</Show>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
return pairingUrl(
{
urls: host ? [host] : props.info.urls.slice(0, 1),
username: props.info.username,
password: props.info.password,
},
host,
)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -20,6 +20,20 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr tailscale",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
+12 -1
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -18,6 +19,7 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -41,6 +43,7 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -187,6 +190,7 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -216,7 +220,11 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
...(multiple()
? [
{
@@ -282,6 +290,9 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
+2
View File
@@ -11,6 +11,7 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -44,6 +45,7 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
@@ -4,6 +4,7 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -40,3 +41,25 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+55 -38
View File
@@ -1,5 +1,5 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,8 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -18,6 +20,9 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
const ConnectServerScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
)
const ConnectLocalScreen = lazy(() =>
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectLocalScreen })),
)
const TargetSessionRouteContent = lazy(() =>
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
)
@@ -26,6 +31,7 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -33,52 +39,62 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
// From the hosted https page, http is mixed content and loopback is the scanning device itself.
const url =
pairing?.urls.find((item) => item === location.origin) ??
pairing?.urls.find((item) => location.protocol !== "https:" || item.startsWith("https:"))
onMount(() => {
if (!pairing || !url) return
servers.add({ type: "http", http: { url, password: pairing.password } })
navigate("/", { replace: true })
})
if (!pairing) return <ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
if (!url) return <ConnectLocalScreen urls={pairing.urls} />
return null
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const servers = useServers()
const connection = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
<Show
when={connection()}
keyed
fallback={
<Show when={servers.pending}>
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
</Show>
}
>
<Show when={connection()} keyed>
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
</Show>
)
@@ -87,9 +103,10 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<Show when={servers.list.length > 0 || servers.pending} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
@@ -10,6 +10,10 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
+2
View File
@@ -67,6 +67,7 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -106,6 +107,7 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
+2 -4
View File
@@ -138,10 +138,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
onCleanup(memory.dispose)
// Tabs of a server that is gone are dropped, but not while the host is still discovering its
// servers: the shell mounts before the local service has connected.
createEffect(() => {
if (!ready() || !recentReady() || servers.pending) return
if (!ready() || !recentReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
@@ -167,7 +165,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
createEffect(() => {
if (!closedReady() || servers.pending) return
if (!closedReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
@@ -303,6 +303,7 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { resolveObjectURL } from "node:buffer"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
const documents = new Map([
@@ -20,19 +20,7 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
return { store, documents }
}
test("loading history and a draft reads no image bytes", async () => {
let reads = 0
const { store } = fixture("history-cache-lazy", async () => {
reads++
return new Blob(["shared screenshot"])
})
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(reads).toBe(0)
})
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
const pending = Promise.withResolvers<Blob | null>()
const started = Promise.withResolvers<void>()
let reads = 0
@@ -41,33 +29,55 @@ test("deduplicates concurrent resolves without invalidating either live referenc
started.resolve()
return pending.promise
})
await store.getItem("history")
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
const history = store.getItem("history")
const draft = store.getItem("draft")
await started.promise
pending.resolve(new Blob(["shared screenshot"]))
const [a, b] = await Promise.all([first, second])
expect(a).toBe(b!)
const [saved, active] = await Promise.all([history, draft])
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
expect(reads).toBe(1)
await store.removeItem("history")
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(reads).toBe(1)
})
test("a document re-read while its image is live gets the URL back without a read", async () => {
test("hydrates repeated references once within one history document", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-repeated", async () => {
reads++
return new Blob(["repeated screenshot"])
})
documents.set(
"history",
JSON.stringify({
entries: Array.from({ length: 100 }, () => ({
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
})),
}),
)
const value = JSON.parse((await store.getItem("history"))!)
expect(value.entries).toHaveLength(100)
expect(
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
).toBe(1)
expect(reads).toBe(1)
})
test("reuses a live URL on remount but reads the latest document", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-remount", async () => {
reads++
return new Blob(["saved screenshot"])
})
const url = await resolveBlobUrl({ id: "history-cache-remount" })
const first = JSON.parse((await store.getItem("history"))!)
const changed = JSON.parse(documents.get("history")!)
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
documents.set("history", JSON.stringify(changed))
const second = JSON.parse((await store.getItem("history"))!)
expect(second.entries[0].prompt[0].content).toBe("new admission")
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
expect(reads).toBe(1)
})
@@ -79,34 +89,33 @@ test("reuses a just-stored attachment without a round trip", async () => {
})
const reference = await store.putBlob(new Blob(["pending admission"]))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
expect(reads).toBe(0)
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
})
test("does not retain a missing blob result", async () => {
let reads = 0
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
expect(reads).toBe(2)
})
test("retries after a failed blob read", async () => {
let reads = 0
fixture("history-cache-failure", async () => {
const { store } = fixture("history-cache-failure", async () => {
if (++reads === 1) throw new Error("temporary storage failure")
return new Blob(["recovered"])
})
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
expect(reads).toBe(2)
})
test("keeps different blob IDs independent", async () => {
const reads: string[] = []
createDraftStore({
get: async () => null,
const store = createDraftStore({
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
set: async () => [],
remove: async () => {},
putBlob: async () => "unused",
@@ -115,8 +124,10 @@ test("keeps different blob IDs independent", async () => {
return new Blob([id])
},
})
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
expect(urls[0]).not.toBe(urls[1])
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
const value = JSON.parse((await store.getItem("history"))!)
expect(value[0].blob.url).not.toBe(value[1].blob.url)
expect(
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
).toEqual(reads)
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
})
@@ -5,7 +5,7 @@ import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -103,11 +103,15 @@ describe("prompt persistence", () => {
}),
}))
await root.session.ready.promise
// Bytes stay in the store until the image is shown or sent.
expect(root.session.current()).toEqual([
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
])
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
@@ -233,7 +237,7 @@ describe("prompt persistence", () => {
})
})
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
test("moves image data URLs into blobs and hydrates object URLs", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
@@ -254,8 +258,8 @@ test("moves image data URLs into blobs and resolves object URLs on demand", asyn
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
expect(documents.get("prompt")).not.toContain("dataUrl")
const value = JSON.parse((await store.getItem("prompt"))!)
expect(value.prompt[0].blob).toEqual({ id: "1" })
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
expect(value.prompt[0].blob.id).toBe("1")
expect(value.prompt[0].blob.url).toStartWith("blob:")
})
test("does not let delayed blob migration overwrite a newer draft", async () => {
+5 -5
View File
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.muted} onMouseUp={props.onClose}>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.muted}>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.8",
"version": "2.0.7",
"type": "module",
"license": "MIT",
"bin": {
+6 -1
View File
@@ -2,6 +2,7 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -18,6 +19,8 @@ export default Runtime.handler(
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
// Fragment, not query: the credential must never reach app.opencode.ai.
const link = `https://app.opencode.ai/connect#${base64Encode(JSON.stringify(info))}`
process.stdout.write(
[
"",
@@ -28,11 +31,13 @@ export default Runtime.handler(
"",
" Scan to pair",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
renderUnicodeCompact(link, { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
"",
].join(EOL) + EOL,
)
+10 -12
View File
@@ -10,18 +10,16 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
})
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
+59
View File
@@ -1,4 +1,5 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { ServerProcess } from "@opencode/server/process"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -7,11 +8,69 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
import { it } from "../../core/test/lib/effect"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
it.live("serves the web shell and assets before server authentication", () =>
Effect.gen(function* () {
const transform = yield* WebUi.handler({
assets: {
"index.html": "<html><body>connect</body></html>",
"_assets/app.js": "console.log('connect')",
"_assets/app.css": "body { color: black; }",
"icons/icon.svg": "<svg></svg>",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
"sw.js": "service worker",
},
})
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
transform,
)
const origin = HttpServer.formatAddress(server.address)
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
"/icons/icon.svg",
"/font.woff2",
"/sw.js",
],
(pathname) =>
Effect.gen(function* () {
yield* Effect.forEach(["GET", "HEAD"], (method) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
yield* Effect.promise(() => response.arrayBuffer())
}),
)
}),
)
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
yield* Effect.promise(() => response.arrayBuffer())
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.8",
"version": "2.0.7",
"type": "module",
"license": "MIT",
"repository": {
-18
View File
@@ -1,27 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const result = await Bun.build({
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
files.filter((file) => !file.endsWith(".d.ts")),
),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
splitting: true,
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Client")
+1 -3
View File
@@ -131,9 +131,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}).pipe(
Effect.repeat({
until: Option.isSome,
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
// like the Promise variant rather than by attempt count.
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
}),
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
)
+61 -65
View File
@@ -8,9 +8,7 @@ export type LocationPublicRef = { directory: string }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderCompaction = { type: "summary" } | { type: "native" }
export type ProviderTransport = "http" | "websocket"
export type ProviderSettings = { [x: string]: any }
export type AgentColor = string
@@ -220,8 +218,19 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
export type ProviderTransport = "http" | "websocket"
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
@@ -456,19 +465,11 @@ export type V2EventServerConnected = {
data: {}
}
export type ProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: any }
export type ConfigProviderSettings = {
timeout?: number | false
chunkTimeout?: number
compaction?: ProviderCompaction
transport?: ProviderTransport
} & { [x: string]: JsonValue | null }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
@@ -1442,6 +1443,20 @@ export type ModelCompatibility = {
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -1656,31 +1671,6 @@ export type SessionInboxMove = {
payload: SessionInboxMovePayload
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
}
export type ModelVariant = {
id: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionRevertStaged = {
@@ -1864,6 +1854,29 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
compaction?: ProviderCompaction
transport?: ProviderTransport
settings?: { [x: string]: any }
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type FormField1 =
| FormStringField1
| FormNumberField1
@@ -1889,27 +1902,6 @@ export type ReferenceInfo = {
source: ReferenceSource
}
export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
package?: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
cost: Array<ModelCost>
status: "alpha" | "beta" | "deprecated" | "active"
enabled: boolean
limit: { context: number; input?: number; output: number }
}
export type AgentInfo = {
id: string
name: string
@@ -2093,27 +2085,31 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
canonical?: string
name?: string
env?: Array<string>
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
compaction?: ProviderCompaction
transport?: ProviderTransport
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: ConfigProviderSettings
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
+3 -4
View File
@@ -1,5 +1,6 @@
export type EnsureTiming = {
readonly pollInterval: number
readonly attempts: number
readonly requestTimeout: number
readonly spawnDelay: number
readonly maxSpawnDelay: number
@@ -10,11 +11,9 @@ export type EnsureTiming = {
const timings = new WeakMap<object, EnsureTiming>()
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
// time a client waits for it. Probes are sequential, so the wait between them only matters while
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
export const defaultEnsureTiming: EnsureTiming = {
pollInterval: 25,
pollInterval: 100,
attempts: 1_200,
requestTimeout: 2_000,
spawnDelay: 5_000,
maxSpawnDelay: 30_000,
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
const timing = {
pollInterval: 20,
attempts: 120,
requestTimeout: 100,
spawnDelay: 200,
maxSpawnDelay: 1_200,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.8",
"version": "2.0.7",
"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.8",
"version": "2.0.7",
"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.8",
"version": "2.0.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.8",
"version": "2.0.7",
"$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.8",
"version": "2.0.7",
"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.8",
"version": "2.0.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.8",
"version": "2.0.7",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+1 -6
View File
@@ -128,8 +128,6 @@ function prepareOptions(model: Info, pkg: string) {
const customFetch = options.fetch
const chunkTimeout = options.chunkTimeout
delete options.chunkTimeout
delete options.compaction
delete options.transport
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const opts = { ...(init ?? {}) }
const signals = [
@@ -390,10 +388,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
if (settings === undefined) return undefined
const result = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
key,
),
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
return Object.keys(result).length === 0 ? undefined : result
@@ -68,6 +68,8 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
if (item.transport !== undefined) provider.transport = item.transport
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
@@ -114,6 +116,8 @@ export const Plugin = define({
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
if (config.transport !== undefined) model.transport = config.transport
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
+1 -1
View File
@@ -1,7 +1,7 @@
export * as Watcher from "./watcher.js"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper.js"
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode/schema/filesystem"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
+14 -61
View File
@@ -36,7 +36,6 @@ export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
const COMPLETED_LIMIT = 25
export type Info = {
id: string
@@ -58,7 +57,6 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
consumed: boolean
recovery?: Recovery
}
@@ -71,7 +69,6 @@ type FinishResult = {
info?: Info
done?: Deferred.Deferred<Info>
scope?: Scope.Closeable
generation?: Scope.Closeable
}
type BackgroundResult = {
@@ -84,12 +81,11 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
generation: Scope.Closeable
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info; generation: Scope.Closeable }
| { type: "finished"; info: Info }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
@@ -172,9 +168,6 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
* wait results stay available for background registration and acknowledgment.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
@@ -183,19 +176,6 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const consume = (id: string, generation: Scope.Closeable) =>
SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(id)
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
const next = new Map(jobs)
// Order history by first consumption, not by start time or subsequent reads.
next.delete(id)
next.set(id, { ...job, consumed: true })
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
return next
})
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
@@ -280,7 +260,6 @@ export const make = Effect.gen(function* () {
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
consumed: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
@@ -301,19 +280,12 @@ export const make = Effect.gen(function* () {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
return yield* Effect.gen(function* () {
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
}).pipe(
// Recoverable wait -> background is a supported handoff, even after failure.
Effect.tap((result) =>
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
),
)
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
if (info._tag === "Some") return { info: info.value, timedOut: false }
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
@@ -331,10 +303,10 @@ export const make = Effect.gen(function* () {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
@@ -342,18 +314,12 @@ export const make = Effect.gen(function* () {
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") {
yield* consume(input.id, result.generation)
return { type: "finished", info: result.info }
}
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
Effect.ensuring(removeBlock(input)),
)
).pipe(Effect.ensuring(removeBlock(input)))
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
@@ -417,7 +383,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
@@ -428,15 +394,11 @@ export const make = Effect.gen(function* () {
},
}
yield* persistBackground(next)
return [
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
new Map(jobs).set(id, next),
]
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
if (result.generation) yield* consume(id, result.generation)
return result.info
})
@@ -452,16 +414,7 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
Effect.gen(function* () {
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
if (!entry || entry[1].info.status === "running") return jobs
const next = new Map(jobs)
next.delete(entry[0])
return next
}),
),
kv.remove(`${backgroundPrefix}${notificationID}`),
)
return Service.of({
+5 -9
View File
@@ -118,9 +118,9 @@ export interface Resolved {
/** Catalog token limits used by Core for context management. */
readonly limit: Info["limit"]
/** Model policy overrides the provider policy; omitted means local compaction. */
readonly compaction?: Provider.Compaction
readonly compaction?: Info["compaction"]
/** Model transport overrides the provider transport; omitted means HTTP. */
readonly transport?: Provider.Transport
readonly transport?: Info["transport"]
}
export interface Interface {
@@ -178,11 +178,7 @@ export const fromCatalogModel = (
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
Effect.flatMap((resolved) => {
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
if (
model.settings?.compaction?.type !== "native" ||
resolved.route.compact?.trigger ||
resolved.route.compact?.endpoint
)
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
return Effect.succeed(resolved)
return Effect.fail(
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
@@ -381,8 +377,8 @@ export const layer = Layer.effect(
capabilities: selected.capabilities,
cost: selected.cost,
limit: selected.limit,
compaction: runtimeInfo.settings?.compaction,
transport: runtimeInfo.settings?.transport,
compaction: selected.compaction,
transport: selected.transport,
}
})
return Service.of({
+2
View File
@@ -188,6 +188,8 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compaction: model.compaction ?? provider?.compaction,
transport: model.transport ?? provider?.transport,
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
-29
View File
@@ -1,29 +0,0 @@
export * as NativeCompactionPlugin from "./compaction.js"
import { LLMClient, Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { SessionCompaction } from "../session/compaction.js"
import type { PluginInternal } from "./internal.js"
export const Plugin = define({
id: "opencode.compaction.native",
effect: Effect.fn("NativeCompactionPlugin")(function* () {
const llm = yield* LLMClient.Service
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => {
editor.native((input) => {
const request = input.request
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
return Effect.gen(function* () {
const retained = yield* input.retained
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
})
if (LLMClient.canCompact(request))
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
return undefined
})
})
}),
} satisfies PluginInternal.InternalPlugin)
-1
View File
@@ -453,7 +453,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
-8
View File
@@ -1,6 +1,5 @@
export * as PluginInternal from "./internal.js"
import { LLMClient } from "@opencode/ai"
import type { Plugin } from "@opencode/plugin/effect/plugin"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { httpClient } from "@opencode/util/effect/app-node-platform"
@@ -13,7 +12,6 @@ import { Provider } from "../provider.js"
import { Command } from "../command.js"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { llmClient } from "../effect/app-node-platform.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
@@ -86,7 +84,6 @@ import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -95,7 +92,6 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
@@ -124,7 +120,6 @@ const services = [
Integration.Service,
Job.Service,
KV.Service,
LLMClient.Service,
Location.Service,
ModelsDev.Service,
Mcp.Service,
@@ -176,7 +171,6 @@ export const requirements = LayerNode.group([
Integration.node,
Job.node,
KV.node,
llmClient,
Location.node,
ModelsDev.node,
Mcp.node,
@@ -206,7 +200,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ToolInputRepairPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
BrowserPlugin,
ConfigMcpPlugin.Plugin,
@@ -219,7 +212,6 @@ const pre = [
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
NativeCompactionPlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
+1 -3
View File
@@ -168,9 +168,7 @@ export const AzurePlugin = define({
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: item.provider.settings?.transport ?? "websocket",
})
draft.transport = item.provider.transport ?? "websocket"
})
}
}
+1 -3
View File
@@ -270,9 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
@@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
@@ -132,13 +131,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.as(
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
? snapshot.config
: undefined,
),
),
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
+1 -3
View File
@@ -98,9 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
})
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
})
}
})
@@ -1,170 +0,0 @@
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
import { definition } from "../tool/runtime.js"
// Repairs apply only when the input schema unambiguously supports them:
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
// - Optional null or empty-object placeholder: { limit: null } -> {}
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
// - Nullable field: { count: "2" } -> { count: 2 }
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const maxDepth = 6
export const Plugin = define({
id: "opencode.tool.input.repair",
effect: (ctx) =>
ctx.tool.hook("execute.before", (event) =>
Effect.gen(function* () {
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
if (event.tool === "execute") return
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
if (!tool) return
const schema = definition(tool).inputSchema
if (schema.type !== "object") return
event.input = repair(event.input, schema, schema, 0)
}),
),
})
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
if (depth > maxDepth) return value
if (typeof schema.$ref === "string") {
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
? root.$defs
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
? root.definitions
: undefined
if (!Predicate.isObject(definitions)) return value
const target = Object.fromEntries(
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
Predicate.isObject(entry[1]),
),
)[
schema.$ref
.slice(schema.$ref.lastIndexOf("/") + 1)
.replaceAll("~1", "/")
.replaceAll("~0", "~")
]
return target ? repair(value, target, root, depth + 1) : value
}
if (Array.isArray(schema.type)) {
if (value === null && schema.type.includes("null")) return value
if (schema.type.includes(typeof value)) return value
const types = schema.type.filter((type) => type !== "null")
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
}
if (schema.type === undefined) {
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
if (!Array.isArray(branches) || value === null) return value
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
}
if (schema.type === "number" || schema.type === "integer") {
if (typeof value !== "string" || value.trim() === "") return value
const parsed = Number(value)
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
}
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
if (schema.type === "object") return repairObject(value, schema, root, depth)
if (schema.type === "array") return repairArray(value, schema, root, depth)
return value
}
function repairObject(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
if (!Predicate.isObject(parsed)) return value
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
const required = Array.isArray(schema.required) ? schema.required : []
const patterned = Predicate.isObject(schema.patternProperties)
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
const current = result[key]
const declared = Object.hasOwn(properties, key)
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
const next = { ...result }
delete next[key]
return next
}
if (!Predicate.isObject(property)) return result
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
// constants, references and nullable flags may accept them, so those are left alone.
const plain =
typeof property.type === "string" &&
property.type !== "null" &&
!composed &&
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
const next = { ...result }
delete next[key]
return next
}
const repaired = repair(current, property, root, depth + 1)
return repaired === current ? result : { ...result, [key]: repaired }
}, parsed)
}
function repairArray(
value: unknown,
schema: JsonSchema.JsonSchema,
root: JsonSchema.JsonSchema,
depth: number,
): unknown {
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: undefined
if (Array.isArray(parsed)) {
const repaired = parsed.map((item, index) => {
const member = tuple
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
: schema.items
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
})
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
}
if (tuple || !Predicate.isObject(schema.items)) return value
const repaired = repair(value, schema.items, root, depth + 1)
const type = schema.items.type
const compatible =
type === "object"
? Predicate.isObject(repaired)
: type === "array"
? Array.isArray(repaired)
: type === "integer"
? typeof repaired === "number" && Number.isSafeInteger(repaired)
: type === "number"
? typeof repaired === "number" && Number.isFinite(repaired)
: (type === "string" || type === "boolean") && typeof repaired === type
return compatible ? [repaired] : value
}
+3 -9
View File
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
/** opencode settings consumed in Core; native packages never receive them. */
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
export function nativeSettings(settings: Settings): Settings {
return Struct.omit(settings, CORE_KEYS)
return Struct.omit(settings, TRANSPORT_KEYS)
}
export function mergeOverlay(
@@ -177,12 +177,6 @@ export function mergeHeaders(
export const Request = Provider.Request
export type Request = Provider.Request
export const Compaction = Provider.Compaction
export type Compaction = Provider.Compaction
export const Transport = Provider.Transport
export type Transport = Provider.Transport
export const Settings = Provider.Settings
export type Settings = Provider.Settings
+29 -45
View File
@@ -10,9 +10,7 @@ import {
LLMRequest,
Message,
type ContentPart,
type Usage,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -93,25 +91,8 @@ export type Settings = {
tokens: number
}
export type NativeInput = {
readonly request: LLMRequest
readonly options: StreamOptions
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
readonly retained: Effect.Effect<ReadonlyArray<Message>>
}
export type NativeResult = {
readonly replacement: ReadonlyArray<Message>
readonly usage?: Usage
}
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
export type Editor = {
configure: (settings: Partial<Settings>) => void
/** Later registrations take precedence. */
native: (strategy: NativeStrategy) => void
}
export type AutoInput = {
@@ -399,18 +380,15 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const db = (yield* Database.Service).db
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
const state = State.create<Settings, Editor>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
editor: (editor) => ({
configure: (settings) => {
if (settings.auto !== undefined) editor.auto = settings.auto
if (settings.buffer !== undefined) editor.buffer = settings.buffer
if (settings.tokens !== undefined) editor.tokens = settings.tokens
},
native: (strategy) => {
editor.native.push(strategy)
},
}),
})
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
@@ -526,23 +504,6 @@ export const layer = Layer.effect(
return yield* reject(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const native = state
.get()
.native.toReversed()
.map((strategy) =>
strategy({
request,
options: prepared.options,
retained: original(context.session.id).pipe(
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
),
}),
)
.find((effect) => effect !== undefined)
if (!native)
return yield* reject(
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: context.agent.id,
model: context.model.ref,
@@ -553,7 +514,25 @@ export const layer = Layer.effect(
Effect.gen(function* () {
// Transient provider failures retry like any other request; only a known automatic overflow permits
// local recovery, and nothing is installed until the provider returns a checkpoint.
const result = yield* restore(native.pipe(transient))
const result = yield* restore(
Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
const result = yield* llm
.compact(request, { ...prepared.options, mechanism: "trigger" })
.pipe(transient)
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
}
if (LLMClient.canCompact(request))
return yield* llm
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
.pipe(transient)
// Model resolution admits provider policies only for routes with a compaction operation.
return yield* Effect.die(
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
)
}),
)
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
if (usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
@@ -736,7 +715,7 @@ export const layer = Layer.effect(
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
const request = { ...input, reason: "auto" as const }
if (input.overflow) return yield* recoverLocally(request)
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
return yield* executeProvider(request)
})
const required = (input: RequiredInput) => {
@@ -759,7 +738,12 @@ export const layer = Layer.effect(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
return estimateTokens(input) >= promptCeiling
const policy = input.resolved.compaction
const threshold =
policy?.mode === "provider" && policy.threshold !== undefined
? Math.min(policy.threshold, promptCeiling)
: promptCeiling
return estimateTokens(input) >= threshold
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
if (findTailStart(input.messages, state.get().tokens) === undefined)
@@ -787,7 +771,7 @@ export const layer = Layer.effect(
inputID: input.inputID,
started: input.started,
}
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
},
}),
)
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
-2
View File
@@ -39,7 +39,6 @@ type Data = {
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -221,7 +220,6 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
list: () => Effect.sync(() => Array.from(state.get().tools.values())),
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
-6
View File
@@ -155,18 +155,14 @@ it.effect("projects request settings, headers, and body overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: unknown
let options: Record<string, unknown> | undefined
yield* aisdk.hook.sdk((event) => {
body = event.options.body
options = event.options
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const input = model("@ai-sdk/google", {
apiKey: "secret",
thinkingConfig: { thinkingBudget: 1024 },
compaction: { type: "native" },
transport: "websocket",
})
const resolved = yield* aisdk.model({
...input,
@@ -189,8 +185,6 @@ it.effect("projects request settings, headers, and body overlays", () =>
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
expect(options).not.toHaveProperty("compaction")
expect(options).not.toHaveProperty("transport")
}),
)
+25 -16
View File
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
Effect.gen(function* () {
const models = yield* Model.Service
yield* addPlugin([
@@ -44,10 +44,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { compaction: { type: "native" } },
compaction: { mode: "provider", threshold: 120_000 },
models: {
native: {},
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
reset: { compaction: { mode: "provider" } },
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
unsupported: { package: "@opencode/ai/providers/openai/chat" },
},
},
@@ -60,9 +62,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
const local = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("local")))
const unsupported = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
const defaultModel = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("chat")))
expect(native.settings?.compaction).toEqual({ type: "native" })
expect(local.settings?.compaction).toEqual({ type: "summary" })
expect(defaultModel.settings?.compaction).toBeUndefined()
expect(native.compaction).toEqual({ mode: "provider", threshold: 120_000 })
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("reset")))?.compaction).toEqual({
mode: "provider",
})
expect((yield* models.get(Provider.ID.make("custom"), Model.ID.make("threshold")))?.compaction).toEqual({
mode: "provider",
threshold: 90_000,
})
expect(local.compaction).toEqual({ mode: "local" })
expect(defaultModel.compaction).toBeUndefined()
yield* ModelResolver.fromCatalogModel(native)
yield* ModelResolver.fromCatalogModel(local)
yield* ModelResolver.fromCatalogModel(defaultModel)
@@ -83,8 +92,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
providers: {
custom: {
package: "@opencode/ai/providers/openai/responses",
settings: { transport: "http" },
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
transport: "http",
models: { inherited: {}, override: { transport: "websocket" } },
},
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
},
@@ -94,9 +103,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
expect(inherited.settings?.transport).toBe("http")
expect(override.settings?.transport).toBe("websocket")
expect(untouched.settings?.transport).toBeUndefined()
expect(inherited.transport).toBe("http")
expect(override.transport).toBe("websocket")
expect(untouched.transport).toBeUndefined()
}),
)
@@ -122,7 +131,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
@@ -130,16 +139,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
info: decode({
providers: {
[builtin.id]: {
settings: { transport: "http" },
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { Ignore } from "@opencode/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper.js"
import { createWrapper } from "@parcel/watcher/wrapper"
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
-1
View File
@@ -72,7 +72,6 @@ export const registerToolPlugin = <R>(
tool: {
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
-1
View File
@@ -133,7 +133,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
-1
View File
@@ -73,7 +73,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
list: () => Effect.die("unused tool.list"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
@@ -473,10 +473,10 @@ describe("AzurePlugin", () => {
yield* addPlugin()
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
expect(responses.settings?.transport).toBe("websocket")
expect(responses.transport).toBe("websocket")
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
const model = required(yield* service.get(Provider.ID.azure, modelID))
expect(model.settings?.transport).toBeUndefined()
expect(model.transport).toBeUndefined()
}
}),
),
@@ -205,7 +205,7 @@ describe("OpenAIPlugin", () => {
expect(model.package).toBe("@opencode/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(model.settings?.transport).toBe("websocket")
expect(model.transport).toBe("websocket")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.baseURL).toBe("https://api.openai.com/v1")
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
@@ -236,7 +236,7 @@ describe("OpenAIPlugin", () => {
id: "deployment-responses",
provider: Provider.ID.azure,
})
const prepare = (preference?: Provider.Transport) =>
const prepare = (preference?: Model.Info["transport"]) =>
Effect.gen(function* () {
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },

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