mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abcab6643 | ||
|
|
dd71cdbd84 | ||
|
|
1c90e8f01c | ||
|
|
9843d13c29 | ||
|
|
871db0025e | ||
|
|
7b7a67080e | ||
|
|
3361953fe1 | ||
|
|
e9201ce933 | ||
|
|
b38d72e174 | ||
|
|
e1e2efe8ec | ||
|
|
c2b171d7c4 | ||
|
|
c3bf8864b9 | ||
|
|
3693f56374 | ||
|
|
971f4e558e | ||
|
|
dd95e3f2ed | ||
|
|
dfde4628e3 | ||
|
|
e755fce13d | ||
|
|
5b565e8923 | ||
|
|
ec8d95f1f2 | ||
|
|
ea52d18ec5 | ||
|
|
0f9504d6c5 | ||
|
|
f6353a48b5 | ||
|
|
4facc03371 | ||
|
|
485a027c1a | ||
|
|
988a66ca4f | ||
|
|
98c6b41573 | ||
|
|
4ee9bf0213 | ||
|
|
681338c533 | ||
|
|
5464aa4a74 | ||
|
|
36083c9520 | ||
|
|
168aba6183 | ||
|
|
ec19dc09c7 | ||
|
|
fca4701688 | ||
|
|
c720ca20ed | ||
|
|
dae1e8abbd | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
db80806651 |
@@ -184,6 +184,7 @@ 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.
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -100,7 +99,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -414,9 +412,7 @@
|
||||
"@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": {
|
||||
@@ -3343,8 +3339,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3639,8 +3633,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3779,8 +3771,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -3907,16 +3897,12 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4461,8 +4447,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -4473,7 +4457,7 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
@@ -4735,8 +4719,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5491,8 +5473,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5617,8 +5597,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6445,10 +6423,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6489,8 +6463,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6521,8 +6493,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6925,18 +6895,12 @@
|
||||
|
||||
"@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/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/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -7013,8 +6977,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -7393,8 +7355,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7419,6 +7379,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7567,8 +7529,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -7577,8 +7537,6 @@
|
||||
|
||||
"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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
|
||||
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
|
||||
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
|
||||
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.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: OpenResponsesOptions.Options.fields.parallelToolCalls,
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
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: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: OpenResponsesOptions.Options.fields.store,
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, 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"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
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 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 ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ 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"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
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"),
|
||||
}),
|
||||
])
|
||||
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])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ 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({
|
||||
@@ -391,7 +389,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(Schema.Literals(["auto", "standard_only"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ 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(
|
||||
@@ -1097,35 +1037,12 @@ 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 management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
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 generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,13 @@ 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, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, lenient, 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"
|
||||
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
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 & {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -161,17 +135,50 @@ 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(Schema.String),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
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"
|
||||
>(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 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),
|
||||
@@ -431,44 +438,11 @@ 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 = resolveOptions(request)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ 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,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ 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: 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),
|
||||
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),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -4,6 +4,22 @@ 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,7 +166,89 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
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"))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,21 @@ 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,6 +1945,30 @@ 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(
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -88,7 +87,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -102,6 +102,7 @@ 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
|
||||
@@ -128,6 +129,7 @@ export function AppInterface(props: {
|
||||
defaultServer={props.defaultServer}
|
||||
canonicalLocalServer={props.canonicalLocalServer}
|
||||
servers={props.servers}
|
||||
pending={props.serversPending}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,6 +36,24 @@ 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,6 +48,18 @@ 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,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
@@ -32,6 +34,13 @@ 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +65,12 @@ 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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,7 @@ 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" }),
|
||||
|
||||
@@ -53,6 +53,7 @@ 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,
|
||||
@@ -60,7 +61,9 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
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("")}`),
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -533,12 +536,8 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -549,6 +548,8 @@ 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 () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -250,6 +251,8 @@ 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(
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -327,19 +326,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 = 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 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 olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -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 } from "@/runtime/server/registry"
|
||||
import { ServerConnection, useServers } 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,11 +30,23 @@ 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>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ResolvedDraftContent draft={props.draft} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -9,6 +8,8 @@ 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}`
|
||||
@@ -29,27 +30,23 @@ 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) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
available().map(
|
||||
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -101,9 +98,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -199,6 +199,9 @@ 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(
|
||||
{
|
||||
@@ -258,6 +261,9 @@ 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
|
||||
},
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 "—"
|
||||
@@ -14,7 +20,7 @@ export function createSessionContextFormatter(locale: string) {
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
return dateTime.format(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,23 @@ export function AppRoutes() {
|
||||
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>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
@@ -76,7 +87,7 @@ function TargetServerRoute(props: ParentProps) {
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
return (
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<Show when={servers.list.length > 0 || servers.pending} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
|
||||
@@ -138,8 +138,10 @@ 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()) return
|
||||
if (!ready() || !recentReady() || servers.pending) return
|
||||
const serversSet = new Set(servers.list.map(ServerConnection.key))
|
||||
const next = store.filter((tab) => serversSet.has(tab.server))
|
||||
if (next.length !== store.length) {
|
||||
@@ -165,7 +167,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!closedReady()) return
|
||||
if (!closedReady() || servers.pending) 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)
|
||||
|
||||
@@ -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("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
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.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
<text fg={theme.text.muted} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} fg={theme.text.muted}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
#!/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")
|
||||
|
||||
@@ -131,7 +131,9 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
// 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 })),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
export type ProviderCompaction = { type: "summary" } | { type: "native" }
|
||||
|
||||
export type ProviderTransport = "http" | "websocket"
|
||||
|
||||
export type AgentColor = string
|
||||
|
||||
@@ -218,19 +220,8 @@ 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 } }
|
||||
@@ -465,11 +456,19 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
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 PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
@@ -1443,20 +1442,6 @@ 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
|
||||
@@ -1671,6 +1656,31 @@ 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 = {
|
||||
@@ -1854,29 +1864,6 @@ 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
|
||||
@@ -1902,6 +1889,27 @@ 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
|
||||
@@ -2085,31 +2093,27 @@ 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?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
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?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type EnsureTiming = {
|
||||
readonly pollInterval: number
|
||||
readonly attempts: number
|
||||
readonly requestTimeout: number
|
||||
readonly spawnDelay: number
|
||||
readonly maxSpawnDelay: number
|
||||
@@ -11,9 +10,11 @@ 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: 100,
|
||||
attempts: 1_200,
|
||||
pollInterval: 25,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -128,6 +128,8 @@ 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 = [
|
||||
@@ -388,7 +390,10 @@ 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", "fetch", "timeout"].includes(key),
|
||||
([key]) =>
|
||||
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
|
||||
@@ -68,8 +68,6 @@ 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)
|
||||
@@ -116,8 +114,6 @@ 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,7 +1,7 @@
|
||||
export * as Watcher from "./watcher.js"
|
||||
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { FileSystem } from "@opencode/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
+61
-14
@@ -36,6 +36,7 @@ export type Status = Background["status"]
|
||||
|
||||
const decodeBackground = Schema.decodeUnknownResult(Background)
|
||||
const backgroundPrefix = "job.background/"
|
||||
const COMPLETED_LIMIT = 25
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
@@ -57,6 +58,7 @@ type Active = {
|
||||
scope: Scope.Closeable
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
consumed: boolean
|
||||
recovery?: Recovery
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
generation?: Scope.Closeable
|
||||
}
|
||||
|
||||
type BackgroundResult = {
|
||||
@@ -81,11 +84,12 @@ 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 }
|
||||
| { type: "finished"; info: Info; generation: Scope.Closeable }
|
||||
| { type: "backgrounded"; info: Info }
|
||||
| { type: "wait"; wait: BlockWait }
|
||||
|
||||
@@ -168,6 +172,9 @@ 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
|
||||
@@ -176,6 +183,19 @@ 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}`, {
|
||||
@@ -260,6 +280,7 @@ 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)]
|
||||
@@ -280,12 +301,19 @@ 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 }
|
||||
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 }
|
||||
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),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
|
||||
@@ -303,10 +331,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) }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
|
||||
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
|
||||
return [
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
|
||||
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
|
||||
]
|
||||
})
|
||||
if (result.type === "missing") return undefined
|
||||
if (result.type === "finished") return { type: "finished", info: result.info }
|
||||
if (result.type === "finished") {
|
||||
yield* consume(input.id, result.generation)
|
||||
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.ensuring(removeBlock(input)))
|
||||
).pipe(
|
||||
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
|
||||
Effect.ensuring(removeBlock(input)),
|
||||
)
|
||||
})
|
||||
|
||||
const markBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
@@ -383,7 +417,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) }, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
return [
|
||||
{ info: snapshot(next), done: job.done, scope: job.scope, generation: 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
|
||||
})
|
||||
|
||||
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
kv.remove(`${backgroundPrefix}${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
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -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?: Info["compaction"]
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
readonly transport?: Info["transport"]
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -178,7 +178,11 @@ 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.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
|
||||
if (
|
||||
model.settings?.compaction?.type !== "native" ||
|
||||
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 }),
|
||||
@@ -377,8 +381,8 @@ export const layer = Layer.effect(
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: selected.compaction,
|
||||
transport: selected.transport,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: runtimeInfo.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -188,8 +188,6 @@ 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),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
@@ -453,6 +453,7 @@ 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: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -12,6 +13,7 @@ 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"
|
||||
@@ -84,6 +86,7 @@ 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"
|
||||
@@ -92,6 +95,7 @@ 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"
|
||||
@@ -120,6 +124,7 @@ const services = [
|
||||
Integration.Service,
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
@@ -171,6 +176,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
@@ -200,6 +206,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
@@ -212,6 +219,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -168,7 +168,9 @@ export const AzurePlugin = define({
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.transport = item.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: item.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ 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.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"
|
||||
@@ -131,7 +132,13 @@ 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(undefined)),
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(
|
||||
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
|
||||
? snapshot.config
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
@@ -98,7 +98,9 @@ export const XAIPlugin = define({
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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
|
||||
}
|
||||
@@ -132,11 +132,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
})
|
||||
|
||||
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
|
||||
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, TRANSPORT_KEYS)
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
@@ -177,6 +177,12 @@ 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
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ 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"
|
||||
@@ -91,8 +93,25 @@ 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 = {
|
||||
@@ -380,15 +399,18 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings, Editor>({
|
||||
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
|
||||
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"]) {
|
||||
@@ -504,6 +526,23 @@ 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,
|
||||
@@ -514,25 +553,7 @@ 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(
|
||||
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 result = yield* restore(native.pipe(transient))
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -715,7 +736,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?.mode !== "provider") return yield* execute(request)
|
||||
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
|
||||
return yield* executeProvider(request)
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
@@ -738,12 +759,7 @@ export const layer = Layer.effect(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const policy = input.resolved.compaction
|
||||
const threshold =
|
||||
policy?.mode === "provider" && policy.threshold !== undefined
|
||||
? Math.min(policy.threshold, promptCeiling)
|
||||
: promptCeiling
|
||||
return estimateTokens(input) >= threshold
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
@@ -771,7 +787,7 @@ export const layer = Layer.effect(
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
}
|
||||
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
|
||||
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ 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>"]),
|
||||
|
||||
@@ -39,6 +39,7 @@ 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>
|
||||
}
|
||||
|
||||
@@ -220,6 +221,7 @@ 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()
|
||||
|
||||
@@ -155,14 +155,18 @@ 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,
|
||||
@@ -185,6 +189,8 @@ 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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function required<T>(value: T | undefined): T {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits provider compaction policy with model overrides and rejects unsupported routes", () =>
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
@@ -44,12 +44,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
compaction: { mode: "provider", threshold: 120_000 },
|
||||
settings: { compaction: { type: "native" } },
|
||||
models: {
|
||||
native: {},
|
||||
reset: { compaction: { mode: "provider" } },
|
||||
threshold: { compaction: { mode: "provider", threshold: 90_000 } },
|
||||
local: { compaction: { mode: "local" }, package: "@opencode/ai/providers/openai/chat" },
|
||||
local: { settings: { compaction: { type: "summary" } }, package: "@opencode/ai/providers/openai/chat" },
|
||||
unsupported: { package: "@opencode/ai/providers/openai/chat" },
|
||||
},
|
||||
},
|
||||
@@ -62,16 +60,9 @@ 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.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()
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -92,8 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
transport: "http",
|
||||
models: { inherited: {}, override: { transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -103,9 +94,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.transport).toBe("http")
|
||||
expect(override.transport).toBe("websocket")
|
||||
expect(untouched.transport).toBeUndefined()
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -131,7 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -139,16 +130,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
info: decode({
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
transport: "http",
|
||||
models: { override: { modelID: builtin.model, transport: "websocket" } },
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
|
||||
@@ -72,6 +72,7 @@ export const registerToolPlugin = <R>(
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ 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 ?? {
|
||||
|
||||
@@ -73,6 +73,7 @@ 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.transport).toBe("websocket")
|
||||
expect(responses.settings?.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.transport).toBeUndefined()
|
||||
expect(model.settings?.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.transport).toBe("websocket")
|
||||
expect(model.settings?.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?: Model.Info["transport"]) =>
|
||||
const prepare = (preference?: Provider.Transport) =>
|
||||
Effect.gen(function* () {
|
||||
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
|
||||
@@ -550,14 +550,15 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
|
||||
it.effect("refreshes hosted search with Console config, retains it on failure, and skips unchanged snapshots", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { advertised: false, requests: 0 }
|
||||
const state = { advertised: false, failing: false, requests: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
if (state.failing) return new Response("Unavailable", { status: 502 })
|
||||
return Response.json({
|
||||
providers: {},
|
||||
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
|
||||
@@ -607,10 +608,18 @@ describe("OpencodePlugin", () => {
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.advertised = false
|
||||
state.failing = true
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.failing = false
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(5)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(model?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const identity = {
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
}
|
||||
|
||||
it.effect("repairs tool input before validating its original schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([
|
||||
{ ...ToolInputRepairPlugin.Plugin, revision: "1" },
|
||||
{
|
||||
id: "repairable-tool",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.transform((draft) =>
|
||||
draft.add({
|
||||
name: "repairable",
|
||||
options: { codemode: false },
|
||||
description: "Repairable",
|
||||
input: Schema.Struct({ count: Schema.Int, enabled: Schema.Boolean }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-repair",
|
||||
name: "repairable",
|
||||
input: '{"count":"2","enabled":"true","extra":true}',
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 2, enabled: true }])
|
||||
|
||||
yield* registry.transform((draft) => {
|
||||
draft.update("repairable", (tool) => {
|
||||
tool.input = Schema.Struct({ count: Schema.Boolean, enabled: Schema.Boolean })
|
||||
})
|
||||
})
|
||||
const updated = yield* registry.snapshot()
|
||||
yield* updated.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-updated",
|
||||
name: "repairable",
|
||||
input: { count: "false", enabled: "true" },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([
|
||||
{ count: 2, enabled: true },
|
||||
{ count: false, enabled: true },
|
||||
])
|
||||
|
||||
yield* registry.transform((draft) => draft.remove("repairable"))
|
||||
const removed = yield* registry.snapshot()
|
||||
expect(
|
||||
(yield* removed
|
||||
.execute({
|
||||
...identity,
|
||||
call: { type: "tool-call", id: "call-removed", name: "repairable", input: {} },
|
||||
})
|
||||
.pipe(Effect.flip)).message,
|
||||
).toBe('No tool named "repairable" is currently available. Please use a tool from the available tool list.')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs namespaced inner tool input called from Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
yield* plugins.activate([{ ...ToolInputRepairPlugin.Plugin, revision: "1" }])
|
||||
yield* registry.transform((draft) =>
|
||||
draft.add({
|
||||
name: "count",
|
||||
options: { namespace: "example" },
|
||||
description: "Record a count",
|
||||
input: Schema.Struct({ count: Schema.Int }),
|
||||
execute: (input) => Effect.sync(() => executed.push(input)).pipe(Effect.as({ content: "ok" })),
|
||||
}),
|
||||
)
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
yield* snapshot.execute({
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-codemode-repair",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.example.count({ count: "3" })' },
|
||||
},
|
||||
})
|
||||
expect(executed).toEqual([{ count: 3 }])
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,455 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { ToolInputRepairPlugin } from "@opencode/core/plugin/tool-input-repair"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import type { ToolHooks } from "@opencode/plugin/effect/tool"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, type JsonSchema } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
const event: ToolHooks["execute.before"] = {
|
||||
tool: "test",
|
||||
input,
|
||||
sessionID: Session.ID.make("ses_repair"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_repair"),
|
||||
id: Tool.CallID.make("call_repair"),
|
||||
}
|
||||
const events: ToolHooks = {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
list: () => Effect.succeed([tool]),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
).pipe(Effect.as(event))
|
||||
}
|
||||
|
||||
const object = (properties: Record<string, unknown>, required?: string[]) => ({
|
||||
type: "object" as const,
|
||||
properties,
|
||||
...(required ? { required } : {}),
|
||||
})
|
||||
|
||||
describe("tool input repair plugin", () => {
|
||||
it.effect("preserves valid input identity, nested containers, and unknown properties", () =>
|
||||
Effect.gen(function* () {
|
||||
const nested = { enabled: true }
|
||||
const items = [2, 3]
|
||||
const input = { count: 2, nested, items, extra: "keep" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
count: { type: "integer" },
|
||||
nested: object({ enabled: { type: "boolean" } }),
|
||||
items: { type: "array", items: { type: "number" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).nested).toBe(nested)
|
||||
expect((event.input as typeof input).items).toBe(items)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses root objects and repairs nested stringified containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const schema = object({
|
||||
count: { type: "integer" },
|
||||
item: object({ enabled: { type: "boolean" } }),
|
||||
list: { type: "array", items: { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(
|
||||
(yield* run('{"count":"2","item":"{\\"enabled\\":\\"false\\"}","list":"[\\"3\\"]"}', schema)).input,
|
||||
).toEqual({
|
||||
count: 2,
|
||||
item: { enabled: false },
|
||||
list: [3],
|
||||
})
|
||||
expect((yield* run("{broken", schema)).input).toBe("{broken")
|
||||
expect((yield* run("[]", schema)).input).toBe("[]")
|
||||
expect((yield* run(null, schema)).input).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes extras only from explicitly closed objects without mutating inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
known: "2",
|
||||
extra: true,
|
||||
closed: { keep: "3", extra: true },
|
||||
open: { keep: "4", extra: true },
|
||||
items: [{ keep: "5", extra: true }],
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
known: { type: "integer" },
|
||||
closed: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
open: object({ keep: { type: "integer" } }),
|
||||
items: {
|
||||
type: "array",
|
||||
items: { ...object({ keep: { type: "integer" } }), additionalProperties: false },
|
||||
},
|
||||
}),
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
known: 2,
|
||||
closed: { keep: 3 },
|
||||
open: { keep: 4, extra: true },
|
||||
items: [{ keep: 5 }],
|
||||
})
|
||||
expect(input.extra).toBeTrue()
|
||||
expect(input.closed.extra).toBeTrue()
|
||||
expect(input.items[0]?.extra).toBeTrue()
|
||||
expect((yield* run({ extra: true }, { ...object({}), additionalProperties: false })).input).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown keys when patterned ownership cannot be determined", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { known: 1, match: "2", extra: true }
|
||||
const event = yield* run(input, {
|
||||
...object({ known: { type: "integer" } }),
|
||||
additionalProperties: false,
|
||||
patternProperties: { "^match$": { type: "integer" } },
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((event.input as typeof input).match).toBe("2")
|
||||
expect((event.input as typeof input).extra).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves properties that may belong to composed object schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { name: "example", extra: true }
|
||||
|
||||
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
||||
const event = yield* run(input, {
|
||||
type: "object",
|
||||
[keyword]: [object({ name: { type: "string" } })],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes only optional nonnullable nulls and non-object empty placeholders", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
optional: null,
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
placeholder: {},
|
||||
array: {},
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object(
|
||||
{
|
||||
optional: { type: "string" },
|
||||
required: { type: "string" },
|
||||
nullable: { type: "string", nullable: true },
|
||||
union: { anyOf: [{ type: "integer" }, { type: "null" }] },
|
||||
constant: { anyOf: [{ type: "integer" }, { const: null }] },
|
||||
permissive: { anyOf: [{ type: "integer" }, true] },
|
||||
referenced: { anyOf: [{ type: "integer" }, { $ref: "#/$defs/nullable" }] },
|
||||
placeholder: { type: "integer" },
|
||||
array: { type: "array", items: { type: "string" } },
|
||||
requiredPlaceholder: { type: "boolean" },
|
||||
object: { type: "object" },
|
||||
unknown: {},
|
||||
},
|
||||
["required", "requiredPlaceholder"],
|
||||
),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
required: null,
|
||||
nullable: null,
|
||||
union: null,
|
||||
constant: null,
|
||||
permissive: null,
|
||||
referenced: null,
|
||||
requiredPlaceholder: {},
|
||||
object: {},
|
||||
unknown: null,
|
||||
})
|
||||
expect(input.optional).toBeNull()
|
||||
expect(input.placeholder).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nulls whose validity is hidden inside compositions", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { wrapped: null, enumerated: null, permissive: null, constant: null }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
wrapped: { anyOf: [{ type: ["string", "null"] }] },
|
||||
enumerated: { anyOf: [{ enum: [null, "keep"] }] },
|
||||
permissive: { anyOf: [{}] },
|
||||
constant: { type: "string", const: "keep" },
|
||||
}),
|
||||
)
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves optional-looking nulls when the parent composes requirements", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { value: null }
|
||||
const event = yield* run(input, {
|
||||
...object({ value: { type: "string" } }),
|
||||
allOf: [{ required: ["value"] }],
|
||||
})
|
||||
expect(event.input).toBe(input)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coerces numeric and boolean strings while preserving invalid and existing values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "1.5",
|
||||
integer: "42",
|
||||
enabled: "true",
|
||||
disabled: "false",
|
||||
valid: 3,
|
||||
empty: " ",
|
||||
infinite: "Infinity",
|
||||
fractional: "1.5",
|
||||
unsafe: "9007199254740992",
|
||||
uppercase: "TRUE",
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { type: "number" },
|
||||
integer: { type: "integer" },
|
||||
enabled: { type: "boolean" },
|
||||
disabled: { type: "boolean" },
|
||||
valid: { type: "integer" },
|
||||
empty: { type: "number" },
|
||||
infinite: { type: "number" },
|
||||
fractional: { type: "integer" },
|
||||
unsafe: { type: "integer" },
|
||||
uppercase: { type: "boolean" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 1.5, integer: 42, enabled: true, disabled: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wraps compatible scalars after repairing array items", () =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* run(
|
||||
{
|
||||
text: "one",
|
||||
integer: "42",
|
||||
boolean: "false",
|
||||
item: '{"count":"2"}',
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
},
|
||||
object({
|
||||
text: { type: "array", items: { type: "string" } },
|
||||
integer: { type: "array", items: { type: "integer" } },
|
||||
boolean: { type: "array", items: { type: "boolean" } },
|
||||
item: { type: "array", items: object({ count: { type: "integer" } }) },
|
||||
incompatible: { type: "array", items: { type: "string" } },
|
||||
fractional: { type: "array", items: { type: "integer" } },
|
||||
unconstrained: { type: "array" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
text: ["one"],
|
||||
integer: [42],
|
||||
boolean: [false],
|
||||
item: [{ count: 2 }],
|
||||
incompatible: 2,
|
||||
fractional: 1.5,
|
||||
unconstrained: "4",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs nested question-like inputs without mutating original containers", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = { question: "Pick one", multiple: "false", options: { label: "First", description: null } }
|
||||
const input = { questions: [question] }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
questions: {
|
||||
type: "array",
|
||||
items: object({
|
||||
question: { type: "string" },
|
||||
multiple: { type: "boolean" },
|
||||
options: {
|
||||
type: "array",
|
||||
items: object({ label: { type: "string" }, description: { type: "string" } }, ["label"]),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({
|
||||
questions: [{ question: "Pick one", multiple: false, options: [{ label: "First" }] }],
|
||||
})
|
||||
expect(input).toEqual({ questions: [question] })
|
||||
expect(question.options.description).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs unique nullable alternatives while preserving accepted union values", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
number: "2",
|
||||
boolean: "false",
|
||||
nullable: null,
|
||||
typed: "3",
|
||||
typedBoolean: "true",
|
||||
accepted: "4",
|
||||
valid: 5,
|
||||
}
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
number: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
boolean: { oneOf: [{ type: "boolean" }, { type: "null" }] },
|
||||
nullable: { anyOf: [{ type: "number" }, { type: "null" }] },
|
||||
typed: { type: ["integer", "null"] },
|
||||
typedBoolean: { type: ["boolean", "null"] },
|
||||
accepted: { anyOf: [{ type: "string" }, { type: "number" }] },
|
||||
valid: { type: ["number", "null"] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ ...input, number: 2, boolean: false, typed: 3, typedBoolean: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs tuple positions and rest items while preserving valid array identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = [2, false]
|
||||
const input = { prefix: ["2", "false", "3"], draft: '["4","true"]', valid, scalar: "5" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
prefix: {
|
||||
type: "array",
|
||||
prefixItems: [{ type: "integer" }, { type: "boolean" }],
|
||||
items: { type: "number" },
|
||||
},
|
||||
draft: { type: "array", items: [{ type: "integer" }, { type: "boolean" }] },
|
||||
valid: { type: "array", prefixItems: [{ type: "integer" }, { type: "boolean" }] },
|
||||
scalar: { type: "array", prefixItems: [{ type: "integer" }] },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toEqual({ prefix: [2, false, 3], draft: [4, true], valid, scalar: "5" })
|
||||
expect((event.input as typeof input).valid).toBe(valid)
|
||||
expect(input.prefix).toEqual(["2", "false", "3"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("repairs typed dictionaries and straightforward local references", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = {
|
||||
modern: "2",
|
||||
legacy: "false",
|
||||
nested: { count: "3" },
|
||||
dictionary: { first: "4" },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: "7",
|
||||
}
|
||||
const event = yield* run(input, {
|
||||
...object({
|
||||
modern: { $ref: "#/$defs/integer" },
|
||||
legacy: { $ref: "#/definitions/boolean" },
|
||||
nested: { $ref: "#/$defs/nested" },
|
||||
dictionary: { type: "object", additionalProperties: { $ref: "#/$defs/integer" } },
|
||||
missing: { $ref: "#/$defs/missing" },
|
||||
pointer: { $ref: "#/$defs/nested/properties/count" },
|
||||
escaped: { $ref: "#/$defs/a~1b~0c" },
|
||||
}),
|
||||
$defs: {
|
||||
integer: { type: "integer" },
|
||||
"a/b~c": { type: "integer" },
|
||||
nested: object({ count: { $ref: "#/$defs/integer" } }),
|
||||
},
|
||||
definitions: { boolean: { type: "boolean" } },
|
||||
})
|
||||
|
||||
expect(event.input).toEqual({
|
||||
modern: 2,
|
||||
legacy: false,
|
||||
nested: { count: 3 },
|
||||
dictionary: { first: 4 },
|
||||
missing: "5",
|
||||
pointer: "6",
|
||||
escaped: 7,
|
||||
})
|
||||
expect(input.nested.count).toBe("3")
|
||||
expect(input.dictionary.first).toBe("4")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves ambiguous unions, compositions, and unsupported roots unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = { numeric: "2", objects: { value: "3" }, both: "4", composed: "5", unknown: "6" }
|
||||
const event = yield* run(
|
||||
input,
|
||||
object({
|
||||
numeric: { anyOf: [{ type: "number" }, { type: "integer" }] },
|
||||
objects: {
|
||||
oneOf: [
|
||||
object({ value: { type: "integer" } }, ["value"]),
|
||||
object({ value: { type: "number" } }, ["value"]),
|
||||
],
|
||||
},
|
||||
both: { anyOf: [{ type: "integer" }], oneOf: [{ type: "integer" }] },
|
||||
composed: { allOf: [{ type: "integer" }] },
|
||||
unknown: {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(event.input).toBe(input)
|
||||
expect((yield* run(input, { properties: { numeric: { type: "integer" } } })).input).toBe(input)
|
||||
expect((yield* run(input, { allOf: [object({ numeric: { type: "integer" } })] })).input).toBe(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -28,8 +28,16 @@ describe("Provider", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("passes flat settings to native packages without opencode transport keys", () => {
|
||||
expect(Provider.nativeSettings({ apiKey: "secret", reasoningEffort: "high", chunkTimeout: 1000 })).toEqual({
|
||||
test("passes flat settings to native packages without Core settings", () => {
|
||||
expect(
|
||||
Provider.nativeSettings({
|
||||
apiKey: "secret",
|
||||
reasoningEffort: "high",
|
||||
chunkTimeout: 1000,
|
||||
compaction: { type: "native" },
|
||||
transport: "websocket",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "secret",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
@@ -207,20 +207,13 @@ it.effect("auto compaction estimates current content against the buffered prompt
|
||||
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
|
||||
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
|
||||
const native = (
|
||||
tokens: number,
|
||||
limit: { context: number; input?: number; output: number } = inputLimited,
|
||||
threshold?: number,
|
||||
) => {
|
||||
const native = (tokens: number, limit: { context: number; input?: number; output: number } = inputLimited) => {
|
||||
const selected = input(tokens, limit)
|
||||
return { ...selected, resolved: { ...selected.resolved, compaction: { mode: "provider" as const, threshold } } }
|
||||
return { ...selected, resolved: { ...selected.resolved, compaction: { type: "native" as const } } }
|
||||
}
|
||||
expect(compaction.required(native(251_999))).toBe(false)
|
||||
expect(compaction.required(native(252_000))).toBe(true)
|
||||
expect(compaction.required(native(99_999, inputLimited, 100_000))).toBe(false)
|
||||
expect(compaction.required(native(100_000, inputLimited, 100_000))).toBe(true)
|
||||
expect(compaction.required(native(252_000, inputLimited, 500_000))).toBe(true)
|
||||
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }, 100_000))).toBe(false)
|
||||
expect(compaction.required(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
|
||||
|
||||
const contextLimited = { context: 100_000, output: 10_000 }
|
||||
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -7,6 +7,7 @@ import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
@@ -26,6 +27,7 @@ import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -44,7 +46,8 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
|
||||
const endpoint = options.endpoint ?? false
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
@@ -161,7 +164,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
compaction: { mode: "provider" },
|
||||
compaction: { type: "native" },
|
||||
},
|
||||
)
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
@@ -185,6 +188,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-test-hook"] = event.kind
|
||||
@@ -261,6 +265,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
store,
|
||||
hooks,
|
||||
model,
|
||||
compaction,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -348,7 +353,7 @@ it.live(
|
||||
|
||||
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup(true)
|
||||
const fixture = yield* setup({ endpoint: true })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(yield* fixture.automatic).toEqual({ status: "completed" })
|
||||
@@ -445,6 +450,31 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({ plugin: false })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
|
||||
})
|
||||
yield* fixture.compaction.transform((editor) => {
|
||||
editor.native(() =>
|
||||
Effect.succeed({
|
||||
replacement: [Message.assistant("plugin window")],
|
||||
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
const installed = yield* fixture.checkpoint
|
||||
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
|
||||
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
|
||||
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("retained user budget counts attachments and drops whole oldest messages", () => {
|
||||
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -56,6 +56,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
|
||||
import { IdentityPlugin } from "@opencode/core/plugin/identity"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Config } from "@opencode/core/config"
|
||||
@@ -470,6 +471,7 @@ const layer = Layer.unwrap(
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionCompaction.node,
|
||||
LayerNodePlatform.llmClient,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
@@ -523,6 +525,7 @@ const setup = Effect.gen(function* () {
|
||||
discard: true,
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
@@ -2860,7 +2863,8 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("automatically persists native windows, retains earlier users, and waits for fresh usage", function* (s) {
|
||||
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
|
||||
s.compaction = { mode: "provider", threshold: 10_000 }
|
||||
modelLimits.set("native", { context: 42_000, output: 32_000 })
|
||||
s.compaction = { type: "native" }
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.defaultID, (agent) => {
|
||||
@@ -2911,7 +2915,8 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("recovers an overflowing native window locally from original durable history", function* (s) {
|
||||
s.currentModel = LanguageModel.make({ id: "native", provider: "openai", route: OpenAIResponses.route })
|
||||
s.compaction = { mode: "provider", threshold: 10_000 }
|
||||
modelLimits.set("native", { context: 42_000, output: 32_000 })
|
||||
s.compaction = { type: "native" }
|
||||
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "before-native", 10_000))
|
||||
yield* s.runPrompt("Original durable request")
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -106,6 +106,9 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreModel.Info, Model.Info],
|
||||
[coreProvider.ID, Provider.ID],
|
||||
[coreProvider.Request, Provider.Request],
|
||||
[coreProvider.Compaction, Provider.Compaction],
|
||||
[coreProvider.Transport, Provider.Transport],
|
||||
[coreProvider.Settings, Provider.Settings],
|
||||
[coreProvider.Info, Provider.Info],
|
||||
[corePermission.Effect, Permission.Effect],
|
||||
[corePermission.Rule, Permission.Rule],
|
||||
|
||||
@@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
|
||||
[
|
||||
"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.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
|
||||
@@ -855,6 +855,21 @@ describe("Tool", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("lists registered tools by effective name", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" })
|
||||
|
||||
expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([
|
||||
["echo", "echo"],
|
||||
["acme_count", "count"],
|
||||
])
|
||||
|
||||
yield* service.transform((editor) => editor.remove("echo"))
|
||||
expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "string", content: "hooked", text: "hooked" },
|
||||
{ name: "empty string", content: "", text: "" },
|
||||
|
||||
@@ -39,3 +39,39 @@ CLI preparation uses these channel rules:
|
||||
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
|
||||
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
|
||||
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
|
||||
|
||||
## Startup benchmark
|
||||
|
||||
`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the
|
||||
renderer going idle, so dev-server and bundling costs are not part of the numbers.
|
||||
|
||||
```bash
|
||||
OPENCODE_CHANNEL=dev bun run build && bunx electron-builder --win --dir --config electron-builder.config.ts
|
||||
bun run bench:startup -- --runs 5 # warm service (started once, reused by every launch)
|
||||
bun run bench:startup -- --runs 5 --compare dist/other/OpenCode\ Dev.exe # A/B: alternate launches of two builds
|
||||
bun run bench:startup -- --service cold # each launch spawns the service
|
||||
bun run bench:startup -- --fresh # first launch after an install (profile wiped each time)
|
||||
bun run bench:startup -- --profile-main --profile-renderer --trace # CPU profiles and a Chromium startup trace
|
||||
bun run bench:startup -- --seed "%APPDATA%\ai.opencode.desktop.dev" # restore tabs and drafts from an existing profile
|
||||
```
|
||||
|
||||
The app runs in an isolated home (`%TEMP%\opencode-bench-startup`): its own `%APPDATA%`, XDG directories, OpenCode
|
||||
database, config and service registration, with the developer's `OPENCODE_*` and `OTEL_*` environment stripped
|
||||
(an inherited OTLP endpoint alone adds a network round trip to every CLI exit). It never attaches to or restarts the
|
||||
developer's live service, and only ever kills the process tree it spawned. `--service cold` stops the service before
|
||||
each launch so the desktop has to spawn it; the isolated config directory gives that service a private port. One
|
||||
`--warmup` launch per build is discarded by default because the first launch of a new binary pays the antivirus scan.
|
||||
`--compare` alternates two builds so machine drift affects both equally; they must bundle the same CLI or the desktop
|
||||
restarts the service on the version mismatch.
|
||||
|
||||
Milestones (ms since spawn) come from the main log, the renderer's performance timeline and DOM readiness polled over
|
||||
CDP. Node's bootstrap timing is read from the main process after each run over `--inspect` (nothing attaches until the
|
||||
run is over): `processCreated` → `nodeStart` is Electron's native init, `nodeStart` → `nodeBootstrapped` is Node
|
||||
itself, and `nodeBootstrapped` → `appStarting` is Electron's JavaScript init plus our main bundle up to its first
|
||||
log line. Renderer idle is the start of the first 500 ms window with under 10 % main-thread task time that stays quiet
|
||||
for `--settle-ms`; `rendererTaskMs` is the renderer's total main-thread task time until then. Raw samples are
|
||||
written to `dist/bench-startup`.
|
||||
|
||||
A packaged beta or prod build registers itself as the `opencode://` handler when it starts, even from the bench; the
|
||||
installed app takes the registration back on its next launch. Those channels run with `HTTPS_PROXY` pointed at a
|
||||
closed port (`--offline` forces it for dev) so the updater's first check fails fast instead of reaching GitHub.
|
||||
@@ -91,6 +91,11 @@ for (const channel of channels) {
|
||||
"js-yaml/dist/js-yaml.min.js",
|
||||
"js-yaml/dist/js-yaml.mjs.map",
|
||||
"js-yaml/bin/js-yaml.js",
|
||||
"unrelated/dist/index.js.map",
|
||||
"unrelated/dist/index.cjs.map",
|
||||
"unrelated/dist/index.d.ts",
|
||||
"unrelated/dist/index.d.cts",
|
||||
"unrelated/dist/index.d.ts.map",
|
||||
]) {
|
||||
expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false)
|
||||
}
|
||||
@@ -112,7 +117,10 @@ for (const channel of channels) {
|
||||
"js-yaml/lib/loader.js",
|
||||
"js-yaml/dist/js-yaml.mjs",
|
||||
"debug/src/index.js",
|
||||
"unrelated/dist/index.js.map",
|
||||
"unrelated/dist/index.js",
|
||||
"unrelated/dist/index.cjs",
|
||||
"unrelated/dist/data.json",
|
||||
"unrelated/src/index.ts",
|
||||
...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap(
|
||||
(name) => [`${name}/package.json`, `${name}/LICENSE`],
|
||||
),
|
||||
@@ -202,7 +210,7 @@ for (const channel of ["dev", "beta"] as const) {
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -77,11 +77,14 @@ const getBase = (appId: string): Configuration => ({
|
||||
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
|
||||
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
|
||||
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
|
||||
// These packages execute compiled JavaScript, not their sources or source maps.
|
||||
"!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map",
|
||||
// Nothing executes type declarations or source maps, and every entry costs startup time: the
|
||||
// main process parses the whole asar header before it runs any JavaScript.
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}",
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
|
||||
"!**/node_modules/**/*.{js,cjs,mjs}.map",
|
||||
// These packages execute compiled JavaScript, not their sources.
|
||||
"!**/node_modules/ajv/lib{,/**/*}",
|
||||
"!**/node_modules/ajv-formats/src{,/**/*}",
|
||||
"!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map",
|
||||
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
|
||||
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
|
||||
"!**/node_modules/js-yaml/bin{,/**/*}",
|
||||
@@ -90,7 +93,7 @@ const getBase = (appId: string): Configuration => ({
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli", "opencode-cli.exe"],
|
||||
filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"],
|
||||
},
|
||||
],
|
||||
afterPack: async (context) => {
|
||||
@@ -100,6 +103,8 @@ const getBase = (appId: string): Configuration => ({
|
||||
)
|
||||
const file = await stat(cli)
|
||||
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
|
||||
const version = path.join(path.dirname(cli), "opencode-cli.version")
|
||||
if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`)
|
||||
},
|
||||
mac: {
|
||||
category: "public.app-category.developer-tools",
|
||||
|
||||
@@ -20,16 +20,15 @@
|
||||
"package": "electron-builder --config electron-builder.config.ts",
|
||||
"package:mac": "electron-builder --mac --config electron-builder.config.ts",
|
||||
"package:win": "electron-builder --win --config electron-builder.config.ts",
|
||||
"package:linux": "electron-builder --linux --config electron-builder.config.ts"
|
||||
"package:linux": "electron-builder --linux --config electron-builder.config.ts",
|
||||
"bench:startup": "bun ./scripts/bench-startup.ts"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env bun
|
||||
// Cold-start benchmark for a packaged desktop build.
|
||||
//
|
||||
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
|
||||
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
|
||||
// [--trace] [--out <dir>] [--home <dir>]
|
||||
//
|
||||
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
|
||||
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
|
||||
// attaches to, restarts or reads the developer's live service or state and does not inherit their
|
||||
// telemetry configuration. `warm` starts one service from the bundled CLI before the runs and lets
|
||||
// every launch reuse it; `cold` stops it before each run so the desktop has to spawn it. `--fresh`
|
||||
// wipes the profile before each launch to measure the first launch after an install. `--compare`
|
||||
// alternates launches of a second build so machine drift affects both equally, and `--warmup`
|
||||
// launches are discarded (the first launch of a new binary pays the antivirus scan).
|
||||
//
|
||||
// Milestones come from the main log, the renderer's performance timeline and DOM readiness polled
|
||||
// over CDP, plus Node's own bootstrap timing read from the main process afterwards over --inspect
|
||||
// (nothing attaches until the run is over), which splits the time before the first log line into
|
||||
// Electron/Chromium native init, Node bootstrap and our main bundle. `--profile-main` records a
|
||||
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
|
||||
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
|
||||
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
|
||||
import { spawn } from "node:child_process"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, dirname, join, relative, resolve } from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const args = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
exe: { type: "string" },
|
||||
compare: { type: "string" },
|
||||
runs: { type: "string", default: "5" },
|
||||
warmup: { type: "string", default: "1" },
|
||||
service: { type: "string", default: "warm" },
|
||||
fresh: { type: "boolean", default: false },
|
||||
offline: { type: "boolean", default: false },
|
||||
seed: { type: "string" },
|
||||
home: { type: "string" },
|
||||
out: { type: "string" },
|
||||
"profile-main": { type: "boolean", default: false },
|
||||
"profile-renderer": { type: "boolean", default: false },
|
||||
trace: { type: "boolean", default: false },
|
||||
"settle-ms": { type: "string", default: "1500" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
const packageDir = resolve(import.meta.dirname, "..")
|
||||
const builds = [
|
||||
{ label: args.values.compare ? "A" : "", exe: resolve(args.values.exe ?? defaultExe()) },
|
||||
...(args.values.compare ? [{ label: "B", exe: resolve(args.values.compare) }] : []),
|
||||
]
|
||||
const runs = Number(args.values.runs)
|
||||
const warmup = Number(args.values.warmup)
|
||||
const service = args.values.service === "cold" ? "cold" : "warm"
|
||||
const settleMs = Number(args.values["settle-ms"])
|
||||
const outDir = resolve(args.values.out ?? join(packageDir, "dist", "bench-startup"))
|
||||
const home = resolve(args.values.home ?? join(tmpdir(), "opencode-bench-startup"))
|
||||
for (const build of builds) {
|
||||
if (!existsSync(build.exe))
|
||||
throw new Error(`Packaged executable not found: ${build.exe}. Run 'bun run build && bun run package:win' (or pass --exe).`)
|
||||
}
|
||||
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer")
|
||||
if (!Number.isSafeInteger(warmup) || warmup < 0) throw new Error("--warmup must be a non-negative integer")
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const appId = appIdFor(builds[0].exe)
|
||||
if (builds.some((build) => appIdFor(build.exe) !== appId)) throw new Error("Compared builds must be the same channel")
|
||||
const userData = join(home, "AppData", "Roaming", appId)
|
||||
const paths = {
|
||||
home,
|
||||
appData: join(home, "AppData", "Roaming"),
|
||||
localAppData: join(home, "AppData", "Local"),
|
||||
temp: join(home, "AppData", "Local", "Temp"),
|
||||
db: join(home, ".local", "share", "opencode", "opencode.db"),
|
||||
config: join(home, ".config", "opencode"),
|
||||
registration: join(home, ".local", "state", "opencode", "service.json"),
|
||||
logs: join(userData, "logs"),
|
||||
}
|
||||
prepareHome()
|
||||
// The desktop deletes XDG_STATE_HOME on Windows, so isolation goes through the home directory.
|
||||
// OPENCODE_* and OTEL_* from the developer's shell would otherwise leak into the measured app and
|
||||
// its service (an OTLP endpoint alone adds a network round trip to every CLI exit).
|
||||
const env = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|OTEL_|SENTRY_)/.test(key))),
|
||||
USERPROFILE: home,
|
||||
HOME: home,
|
||||
APPDATA: paths.appData,
|
||||
LOCALAPPDATA: paths.localAppData,
|
||||
TEMP: paths.temp,
|
||||
TMP: paths.temp,
|
||||
XDG_DATA_HOME: join(home, ".local", "share"),
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
XDG_CACHE_HOME: join(home, ".cache"),
|
||||
OPENCODE_DB: paths.db,
|
||||
OPENCODE_CONFIG_DIR: paths.config,
|
||||
// Beta and prod builds check for updates on start; a closed proxy port fails that fast and offline.
|
||||
...(args.values.offline || appId !== "ai.opencode.desktop.dev" ? { HTTPS_PROXY: "http://127.0.0.1:9" } : {}),
|
||||
}
|
||||
const cdpPort = await freePort()
|
||||
// A private service port keeps a cold launch's own service away from the developer's service.
|
||||
const servicePort = await freePort()
|
||||
writeFileSync(join(paths.config, "service.json"), JSON.stringify({ port: servicePort }))
|
||||
const inspectPort = await freePort()
|
||||
let appPid: number | undefined
|
||||
let serviceProcess: ReturnType<typeof spawn> | undefined
|
||||
// Renderer readiness, read over CDP: paint timing plus the DOM states the user actually waits for.
|
||||
const probe = `(() => ({
|
||||
origin: performance.timeOrigin,
|
||||
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
|
||||
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
|
||||
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('[data-timeline-row]').length,
|
||||
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
|
||||
url: location.pathname + location.search,
|
||||
}))()`
|
||||
// Main-process bootstrap timing, read after the run: when the process was created, when Node
|
||||
// started inside it, and when Node's own bootstrap finished and handed control to the entry module.
|
||||
const mainTiming = `JSON.stringify({ created: process.getCreationTime(), origin: performance.timeOrigin, ...performance.nodeTiming.toJSON(), cpu: process.cpuUsage(), rss: process.memoryUsage().rss })`
|
||||
// Renderer navigation and resource timing plus any marks the app emitted, read once the run is settled.
|
||||
const rendererTimeline = `(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0]
|
||||
const resources = performance.getEntriesByType('resource')
|
||||
const scripts = resources.filter((r) => r.initiatorType === 'script' || r.name.endsWith('.js'))
|
||||
return {
|
||||
navigation: nav && { fetchStart: nav.fetchStart, responseEnd: nav.responseEnd, domInteractive: nav.domInteractive, domContentLoaded: nav.domContentLoadedEventEnd, load: nav.loadEventEnd },
|
||||
resources: { count: resources.length, scripts: scripts.length, firstStart: Math.min(...resources.map((r) => r.startTime)), lastEnd: Math.max(...resources.map((r) => r.responseEnd)), bytes: resources.reduce((n, r) => n + (r.encodedBodySize || 0), 0) },
|
||||
paint: Object.fromEntries(performance.getEntriesByType('paint').map((e) => [e.name, e.startTime])),
|
||||
marks: performance.getEntriesByType('mark').map((e) => [e.name, Math.round(e.startTime)]),
|
||||
measures: performance.getEntriesByType('measure').map((e) => [e.name, Math.round(e.startTime), Math.round(e.duration)]),
|
||||
}
|
||||
})()`
|
||||
|
||||
for (const build of builds) console.log(`bench${build.label ? ` ${build.label}` : ""}: ${build.exe}`)
|
||||
console.log(`home: ${home}`)
|
||||
console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpPort}, inspect ${inspectPort}${args.values.fresh ? ", fresh profile per launch" : ""}`)
|
||||
|
||||
if (service === "warm") await warmService()
|
||||
|
||||
const samples: Sample[] = []
|
||||
// A launch that never produces a renderer would otherwise leave an instance behind that every later
|
||||
// launch hands off to through the single-instance lock.
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error(error)
|
||||
await killApp()
|
||||
await stopService()
|
||||
process.exit(1)
|
||||
})
|
||||
for (let run = 1 - warmup; run <= runs; run++) {
|
||||
for (const build of builds) {
|
||||
const sample = await launch(build, run)
|
||||
if (run < 1) {
|
||||
console.log(`warm-up${build.label ? ` ${build.label}` : ""}: shell ${sample.msSinceSpawn.shellVisible} ms`)
|
||||
continue
|
||||
}
|
||||
samples.push(sample)
|
||||
console.log(JSON.stringify(sample))
|
||||
}
|
||||
}
|
||||
await killApp()
|
||||
if (service === "warm") await stopService()
|
||||
|
||||
const summaries = Object.fromEntries(
|
||||
builds.map((build) => {
|
||||
const own = samples.filter((s) => s.build === build.label)
|
||||
const timed = own.filter((s) => !s.profiled && !s.traced)
|
||||
return [build.label || "A", summarize(timed.length ? timed : own)]
|
||||
}),
|
||||
)
|
||||
// Durations between consecutive checkpoints, so "where did the time go" needs no subtraction.
|
||||
const phaseOrder = [
|
||||
["electron native init", "processCreated", "nodeStart"],
|
||||
["node bootstrap", "nodeStart", "nodeBootstrapped"],
|
||||
["electron js init → entry", "nodeBootstrapped", "entryStart"],
|
||||
["entry → chromium ready", "entryStart", "electronReady"],
|
||||
["ready → window shown", "electronReady", "windowVisible"],
|
||||
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
|
||||
["layers → first log line", "bundleEvaluated", "appStarting"],
|
||||
["layers → renderer process", "appStarting", "rendererProcess"],
|
||||
["renderer boot → first paint", "rendererProcess", "firstPaint"],
|
||||
["first paint → shell", "firstPaint", "shellVisible"],
|
||||
["shell → idle", "shellVisible", "rendererIdle"],
|
||||
] as const
|
||||
const phases = Object.fromEntries(
|
||||
builds.map((build) => {
|
||||
const own = samples.filter((s) => s.build === build.label && !s.profiled && !s.traced)
|
||||
const out: Record<string, number> = {}
|
||||
for (const [name, from, to] of phaseOrder) {
|
||||
const deltas = own
|
||||
.map((s) => (s.msSinceSpawn[to] ?? NaN) - (s.msSinceSpawn[from] ?? NaN))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b)
|
||||
if (deltas.length) out[name] = deltas[Math.floor(deltas.length / 2)]
|
||||
}
|
||||
return [build.label || "A", out]
|
||||
}),
|
||||
)
|
||||
const report = { builds, service, runs, warmup, fresh: args.values.fresh, home, summaries, phases, samples }
|
||||
const reportPath = join(outDir, `startup-${Date.now()}.json`)
|
||||
writeFileSync(reportPath, JSON.stringify(report, null, 2))
|
||||
const labels = Object.keys(summaries)
|
||||
const header = `${"".padEnd(32)}${labels.map((label) => (labels.length > 1 ? label : "").padStart(6).padEnd(22)).join("")}`
|
||||
console.log(`\n${service} service${args.values.fresh ? ", fresh profile" : ""} — median (min…max) ms since spawn over ${runs} runs`)
|
||||
console.log(header)
|
||||
for (const key of [...new Set(labels.flatMap((label) => Object.keys(summaries[label])))]) {
|
||||
const cells = labels.map((label) => {
|
||||
const v = summaries[label][key]
|
||||
return v ? `${String(v.median).padStart(6)} (${v.min}…${v.max})`.padEnd(22) : "".padEnd(22)
|
||||
})
|
||||
console.log(`${key.padEnd(32)}${cells.join("")}`)
|
||||
}
|
||||
console.log(`\nphases — median ms`)
|
||||
console.log(header)
|
||||
for (const [name] of phaseOrder) {
|
||||
const cells = labels.map((label) => String(phases[label][name] ?? "").padStart(6).padEnd(22))
|
||||
console.log(`${name.padEnd(32)}${cells.join("")}`)
|
||||
}
|
||||
const idle = (label: string) => samples.filter((s) => s.build === label && !s.profiled && !s.traced).at(-1)
|
||||
console.log(`\nmemory once idle (last run) — working set MB per process, main CPU ms`)
|
||||
for (const label of labels) {
|
||||
const s = idle(label === "A" && labels.length === 1 ? "" : label)
|
||||
if (!s) continue
|
||||
const total = s.processes.reduce((n, p) => n + p.rssMB, 0)
|
||||
console.log(`${(labels.length > 1 ? label : "").padEnd(4)}total ${total} MB · main cpu ${s.mainCpuMs ?? "?"} ms · renderer heap ${s.renderer.jsHeapMB} MB, ${s.renderer.domNodes} nodes, layout ${s.renderer.layoutMs} ms/${s.renderer.layouts}×, style ${s.renderer.styleMs} ms/${s.renderer.styleRecalcs}×`)
|
||||
console.log(` ${s.processes.map((p) => `${p.name} ${p.rssMB}`).join(" · ")}`)
|
||||
}
|
||||
console.log(`\nreport: ${reportPath}`)
|
||||
process.exit(0)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
type Probe = {
|
||||
origin: number
|
||||
firstPaint?: number
|
||||
domInteractive?: number
|
||||
shell: boolean
|
||||
editor: boolean
|
||||
rows: number
|
||||
home: boolean
|
||||
url: string
|
||||
}
|
||||
type Sample = {
|
||||
build: string
|
||||
run: number
|
||||
profiled: boolean
|
||||
traced?: string
|
||||
mainProfile?: string
|
||||
rendererProfile?: string
|
||||
msSinceSpawn: Record<string, number | undefined>
|
||||
final: { url?: string; timelineRows?: number }
|
||||
rendererCpu: { taskMs: number; scriptMs: number }
|
||||
// Renderer main-thread breakdown from Performance.getMetrics at the end of the run.
|
||||
renderer: Record<string, number>
|
||||
rendererTimeline: unknown
|
||||
// Working set per process in the app's tree (main, renderer, GPU, utility) once idle, in MB, and
|
||||
// the main process's CPU time.
|
||||
processes: { name: string; pid: number; rssMB: number }[]
|
||||
mainCpuMs?: number
|
||||
// Every timestamped line from the run's log directory (main, crash, onboarding, window, …) as
|
||||
// [ms since spawn, file, message].
|
||||
timeline: [number, string, string][]
|
||||
}
|
||||
|
||||
async function launch(build: { label: string; exe: string }, run: number): Promise<Sample> {
|
||||
await killApp()
|
||||
if (service === "cold") await stopService()
|
||||
if (args.values.fresh) rmSync(userData, { recursive: true, force: true })
|
||||
const profile = args.values["profile-main"] && run === 1
|
||||
const trace = args.values.trace && run === runs
|
||||
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
|
||||
const launchArgs = [
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
|
||||
...(trace
|
||||
? [
|
||||
"--trace-startup=*,disabled-by-default-v8.cpu_profiler",
|
||||
"--trace-startup-duration=6",
|
||||
"--trace-startup-format=json",
|
||||
`--trace-startup-file=${tracePath}`,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
appPid = child.pid
|
||||
|
||||
let mainProfile: Promise<unknown> | undefined
|
||||
if (profile) mainProfile = profileMain(spawnAt)
|
||||
|
||||
const page = await waitFor(
|
||||
() => targets(cdpPort).then((list) => list.find((t) => t.type === "page" && t.url.startsWith("oc://"))),
|
||||
60_000,
|
||||
)
|
||||
const cdp = await connect(page.webSocketDebuggerUrl)
|
||||
await cdp.send("Runtime.enable")
|
||||
await cdp.send("Performance.enable")
|
||||
const rendererProfile = args.values["profile-renderer"] && run === 1
|
||||
if (rendererProfile) {
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 })
|
||||
await cdp.send("Profiler.start")
|
||||
}
|
||||
// Poll DOM readiness and the renderer's cumulative main-thread task time together. The run ends
|
||||
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
|
||||
const seen: Record<string, number> = {}
|
||||
let last: Probe | undefined
|
||||
let quietSince: number | undefined
|
||||
let taskMs = 0
|
||||
let scriptMs = 0
|
||||
const window: { at: number; task: number }[] = []
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
|
||||
last = result.result?.result?.value as Probe | undefined
|
||||
const t = Date.now() - spawnAt
|
||||
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
|
||||
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
|
||||
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
|
||||
if (last?.home && !seen.homeReady) seen.homeReady = t
|
||||
const metrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
|
||||
const task = (metrics.find((m) => m.name === "TaskDuration")?.value ?? 0) * 1000
|
||||
scriptMs = (metrics.find((m) => m.name === "ScriptDuration")?.value ?? 0) * 1000
|
||||
if (process.env.BENCH_DEBUG && task - taskMs > 5) console.log(`busy +${Date.now() - spawnAt} ${Math.round(task - taskMs)} ms`)
|
||||
taskMs = task
|
||||
window.push({ at: Date.now(), task })
|
||||
while (window.length > 1 && window[1].at <= Date.now() - 500) window.shift()
|
||||
if (task - window[0].task > 50) quietSince = undefined
|
||||
else quietSince ??= window[0].at
|
||||
if (seen.shellVisible && quietSince && Date.now() - quietSince >= settleMs) break
|
||||
await sleep(50)
|
||||
}
|
||||
const rendererIdleMs = quietSince ? quietSince - spawnAt : undefined
|
||||
const rendererProfilePath = rendererProfile ? join(outDir, `renderer-${Date.now()}.cpuprofile`) : undefined
|
||||
if (rendererProfilePath) {
|
||||
const stopped = await cdp.send("Profiler.stop")
|
||||
writeFileSync(rendererProfilePath, JSON.stringify(stopped.result.profile))
|
||||
console.log("renderer profile:", rendererProfilePath)
|
||||
}
|
||||
const finalMetrics = (await cdp.send("Performance.getMetrics")).result?.metrics as { name: string; value: number }[]
|
||||
const metric = (name: string) => finalMetrics.find((m) => m.name === name)?.value ?? 0
|
||||
const renderer = {
|
||||
taskMs: Math.round(metric("TaskDuration") * 1000),
|
||||
scriptMs: Math.round(metric("ScriptDuration") * 1000),
|
||||
layoutMs: Math.round(metric("LayoutDuration") * 1000),
|
||||
styleMs: Math.round(metric("RecalcStyleDuration") * 1000),
|
||||
layouts: metric("LayoutCount"),
|
||||
styleRecalcs: metric("RecalcStyleCount"),
|
||||
domNodes: metric("Nodes"),
|
||||
jsHeapMB: Math.round(metric("JSHeapUsedSize") / 1048576),
|
||||
}
|
||||
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
|
||||
cdp.close()
|
||||
const processes = appPid ? await processTree(appPid) : []
|
||||
const boot = profile ? undefined : await mainBootTiming()
|
||||
await sleep(300)
|
||||
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
|
||||
if (trace) await waitFor(async () => (existsSync(tracePath) && statSync(tracePath).size > 0 ? true : undefined), 20_000)
|
||||
const main = mainLog()
|
||||
const origin = last?.origin ? Math.round(last.origin - spawnAt) : undefined
|
||||
const sample: Sample = {
|
||||
build: build.label,
|
||||
run,
|
||||
profiled: profile || !!rendererProfile,
|
||||
traced: trace ? tracePath : undefined,
|
||||
rendererProfile: rendererProfilePath,
|
||||
msSinceSpawn: {
|
||||
processCreated: boot && Math.round(boot.created - spawnAt),
|
||||
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
|
||||
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
|
||||
entryStart: main.marks.entry && main.marks.entry - spawnAt,
|
||||
electronReady: main.marks.ready && main.marks.ready - spawnAt,
|
||||
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
|
||||
appStarting: main.appStarting && main.appStarting - spawnAt,
|
||||
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
|
||||
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
|
||||
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
|
||||
serviceReady: main.serviceReady && main.serviceReady - spawnAt,
|
||||
rendererProcess: origin,
|
||||
windowVisible: main.windowVisible && main.windowVisible - spawnAt,
|
||||
domInteractive:
|
||||
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
|
||||
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
|
||||
...seen,
|
||||
rendererIdle: rendererIdleMs,
|
||||
},
|
||||
final: { url: last?.url, timelineRows: last?.rows },
|
||||
rendererCpu: { taskMs: Math.round(taskMs), scriptMs: Math.round(scriptMs) },
|
||||
renderer,
|
||||
rendererTimeline: timelineResult.result?.result?.value,
|
||||
processes,
|
||||
mainCpuMs: boot && Math.round((boot.cpu.user + boot.cpu.system) / 1000),
|
||||
timeline: main.timeline.map(([at, file, message]) => [at - spawnAt, file, message]),
|
||||
}
|
||||
if (mainProfile) {
|
||||
const profilePath = join(outDir, `main-${Date.now()}.cpuprofile`)
|
||||
writeFileSync(profilePath, JSON.stringify(await mainProfile))
|
||||
sample.mainProfile = profilePath
|
||||
console.log("main profile:", profilePath)
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
// Read after the run, so the inspector attach cannot influence what was measured.
|
||||
async function mainBootTiming() {
|
||||
const target = (await targets(inspectPort)).find((t) => t.type === "node")
|
||||
if (!target) return undefined
|
||||
const cdp = await connect(target.webSocketDebuggerUrl)
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: mainTiming, returnByValue: true })
|
||||
cdp.close()
|
||||
const value = result.result?.result?.value
|
||||
if (typeof value !== "string") return undefined
|
||||
return JSON.parse(value) as {
|
||||
created: number
|
||||
origin: number
|
||||
nodeStart: number
|
||||
bootstrapComplete: number
|
||||
cpu: { user: number; system: number }
|
||||
rss: number
|
||||
}
|
||||
}
|
||||
|
||||
function defaultExe() {
|
||||
const unpacked = join(packageDir, "dist", process.platform === "win32" ? "win-unpacked" : process.platform === "darwin" ? "mac" : "linux-unpacked")
|
||||
if (!existsSync(unpacked)) return join(unpacked, "OpenCode Dev.exe")
|
||||
const candidate = readdirSync(unpacked).find((f) => (process.platform === "win32" ? f.endsWith(".exe") : f.endsWith(".app") || !f.includes(".")))
|
||||
return join(unpacked, candidate ?? "OpenCode Dev.exe")
|
||||
}
|
||||
|
||||
function appIdFor(executable: string) {
|
||||
const name = basename(executable, ".exe")
|
||||
if (/beta/i.test(name)) return "ai.opencode.desktop.beta"
|
||||
if (/dev/i.test(name)) return "ai.opencode.desktop.dev"
|
||||
return "ai.opencode.desktop"
|
||||
}
|
||||
|
||||
function prepareHome() {
|
||||
for (const dir of [paths.appData, paths.temp, dirname(paths.db), paths.config, dirname(paths.registration), join(home, ".cache")]) mkdirSync(dir, { recursive: true })
|
||||
if (args.values.seed && !existsSync(userData)) {
|
||||
// Seed only the app's own state (tabs, drafts, settings, window placement); Chromium profile
|
||||
// data, caches, logs and the staged CLI are recreated by the app.
|
||||
const seed = resolve(args.values.seed)
|
||||
const keep = /^(drafts\.sqlite(-wal|-shm)?|opencode\.[a-z]+|\.?window-state.*\.json|opencode)$/
|
||||
cpSync(seed, userData, {
|
||||
recursive: true,
|
||||
filter: (source) => source === seed || keep.test(relative(seed, source).split(/[\\/]/)[0] ?? ""),
|
||||
})
|
||||
}
|
||||
mkdirSync(paths.logs, { recursive: true })
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
return new Promise<number>((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
server.close(() => (typeof address === "object" && address ? resolvePort(address.port) : reject(new Error("no port"))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function targets(port: number) {
|
||||
return fetch(`http://127.0.0.1:${port}/json`)
|
||||
.then((r) => r.json() as Promise<{ type: string; url: string; webSocketDebuggerUrl: string }[]>)
|
||||
.catch(() => [] as { type: string; url: string; webSocketDebuggerUrl: string }[])
|
||||
}
|
||||
|
||||
async function waitFor<T>(fn: () => Promise<T | undefined>, timeout: number) {
|
||||
const deadline = Date.now() + timeout
|
||||
while (Date.now() < deadline) {
|
||||
const value = await fn()
|
||||
if (value) return value
|
||||
await sleep(25)
|
||||
}
|
||||
throw new Error("Timed out waiting for the renderer debug target")
|
||||
}
|
||||
|
||||
async function connect(url: string) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => (ws.onopen = r))
|
||||
let id = 0
|
||||
const pending = new Map<number, (v: any) => void>()
|
||||
const events: any[] = []
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(String(ev.data))
|
||||
if (msg.method) events.push(msg)
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)!(msg)
|
||||
pending.delete(msg.id)
|
||||
}
|
||||
}
|
||||
return {
|
||||
events,
|
||||
send: (method: string, params: Record<string, unknown> = {}) =>
|
||||
new Promise<any>((resolvePromise) => {
|
||||
const n = ++id
|
||||
pending.set(n, resolvePromise)
|
||||
ws.send(JSON.stringify({ id: n, method, params }))
|
||||
}),
|
||||
close: () => ws.close(),
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to the main process while it is paused on its first statement, start sampling, release it.
|
||||
async function profileMain(spawnAt: number) {
|
||||
const target = await waitFor(() => targets(inspectPort).then((list) => list.find((t) => t.type === "node")), 30_000)
|
||||
const cdp = await connect(target.webSocketDebuggerUrl)
|
||||
await cdp.send("Runtime.enable")
|
||||
await cdp.send("Debugger.enable")
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 200 })
|
||||
await cdp.send("Profiler.start")
|
||||
await cdp.send("Runtime.runIfWaitingForDebugger")
|
||||
const until = Date.now() + 3000
|
||||
while (!cdp.events.some((e) => e.method === "Debugger.paused") && Date.now() < until) await sleep(10)
|
||||
await cdp.send("Debugger.resume")
|
||||
console.log(`debugger released at +${Date.now() - spawnAt} ms`)
|
||||
await sleep(8000)
|
||||
const result = await cdp.send("Profiler.stop")
|
||||
cdp.close()
|
||||
return result.result.profile
|
||||
}
|
||||
|
||||
// Every timestamped line of the run's log directory, merged across the main log and the scoped logs
|
||||
// (crash, onboarding, window, …) that electron-log writes next to it.
|
||||
function mainLog() {
|
||||
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
|
||||
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
|
||||
const timeline: [number, string, string][] = []
|
||||
let windowShownAt: number | undefined
|
||||
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
|
||||
const marks: Record<string, number> = {}
|
||||
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
|
||||
const text = readFileSync(join(dir!, name), "utf8")
|
||||
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
|
||||
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
|
||||
const line = entry.split(/\r?\n/)[0]
|
||||
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
|
||||
if (!m) continue
|
||||
const message = m[2].replace(/\s*\{.*$/, "").trim()
|
||||
// A window shown before the logger existed reports when it was shown; the line itself is later.
|
||||
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
|
||||
if (shown) windowShownAt = Number(shown)
|
||||
if (/app starting/.test(message))
|
||||
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
|
||||
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a[0] - b[0])
|
||||
const at = (pattern: RegExp) => timeline.find(([, , message]) => pattern.test(message))?.[0]
|
||||
return {
|
||||
timeline,
|
||||
appStarting: at(/app starting/),
|
||||
versionStart: at(/v2 CLI command started/),
|
||||
versionDone: at(/v2 CLI command completed/),
|
||||
serviceStarting: at(/v2 CLI background service starting/),
|
||||
serviceReady: at(/background service ready/),
|
||||
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
|
||||
marks,
|
||||
}
|
||||
}
|
||||
|
||||
// Working set of every process in the launched app's tree once it is idle.
|
||||
async function processTree(root: number) {
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
? [
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
`$all = Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, Name, WorkingSetSize, CommandLine; $ids = @(${root}); do { $before = $ids.Count; $ids = @($ids + ($all | Where-Object { $ids -contains $_.ParentProcessId } | ForEach-Object ProcessId) | Sort-Object -Unique) } while ($ids.Count -ne $before); $all | Where-Object { $ids -contains $_.ProcessId } | ForEach-Object { '{0}|{1}|{2}|{3}' -f $_.ProcessId, $_.Name, $_.WorkingSetSize, (($_.CommandLine -split ' ' | Where-Object { $_ -like '--type=*' }) -join '') } `,
|
||||
],
|
||||
]
|
||||
: ["sh", ["-c", `ps -eo pid=,ppid=,rss=,comm= | awk -v r=${root} 'BEGIN{ids[r]=1} {p[$1]=$2; rss[$1]=$3; c[$1]=$4} END{for(k=0;k<8;k++) for(i in p) if(p[i] in ids) ids[i]=1; for(i in ids) if(i in rss) print i "|" c[i] "|" rss[i]*1024 "|"}'`]]
|
||||
const out = await new Promise<string>((done) => {
|
||||
const child = spawn(script[0] as string, script[1] as string[], { stdio: ["ignore", "pipe", "ignore"] })
|
||||
let text = ""
|
||||
child.stdout?.on("data", (chunk) => (text += chunk))
|
||||
child.on("close", () => done(text))
|
||||
})
|
||||
return out
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [pid, name, rss, type] = line.split("|")
|
||||
return { pid: Number(pid), name: `${name}${type ? ` ${type.replace("--type=", "")}` : ""}`, rssMB: Math.round(Number(rss) / 1048576) }
|
||||
})
|
||||
}
|
||||
|
||||
function summarize(list: Sample[]) {
|
||||
const values = (s: Sample): Record<string, number | undefined> => ({
|
||||
...s.msSinceSpawn,
|
||||
rendererTaskMs: s.rendererCpu.taskMs,
|
||||
rendererScriptMs: s.rendererCpu.scriptMs,
|
||||
})
|
||||
const keys = [...new Set(list.flatMap((s) => Object.keys(values(s))))]
|
||||
const out: Record<string, { median: number; min: number; max: number }> = {}
|
||||
for (const key of keys) {
|
||||
const sorted = list.map((s) => values(s)[key]).filter((v): v is number => Number.isFinite(v)).sort((a, b) => a - b)
|
||||
if (!sorted.length) continue
|
||||
out[key] = { median: sorted[Math.floor(sorted.length / 2)], min: sorted[0], max: sorted[sorted.length - 1] }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The service must come from the CLI bundled with the executable: the desktop restarts a service
|
||||
// whose version differs from its bundled CLI, which would turn a warm run into a cold one.
|
||||
function bundledCli(exe: string) {
|
||||
const resources = process.platform === "darwin" ? join(dirname(exe), "..", "Resources") : join(dirname(exe), "resources")
|
||||
return join(resources, process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli")
|
||||
}
|
||||
|
||||
async function warmService() {
|
||||
await stopService()
|
||||
const clis = builds.map((build) => bundledCli(build.exe))
|
||||
const identity = (cli: string) => {
|
||||
const version = join(dirname(cli), "opencode-cli.version")
|
||||
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
|
||||
}
|
||||
if (new Set(clis.map(identity)).size > 1)
|
||||
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
|
||||
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
|
||||
serviceProcess.unref()
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(paths.registration)) {
|
||||
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { url?: string }
|
||||
if (registration.url && (await fetch(`${registration.url}/api/info`).then((r) => r.status < 500).catch(() => false))) {
|
||||
console.log(`service warm at ${registration.url}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
await sleep(200)
|
||||
}
|
||||
throw new Error("The bench service did not become ready")
|
||||
}
|
||||
|
||||
async function stopService() {
|
||||
if (existsSync(paths.registration)) {
|
||||
const registration = JSON.parse(readFileSync(paths.registration, "utf8")) as { pid?: number }
|
||||
if (registration.pid) {
|
||||
try {
|
||||
process.kill(registration.pid)
|
||||
} catch {}
|
||||
}
|
||||
rmSync(paths.registration, { force: true })
|
||||
}
|
||||
if (serviceProcess?.pid) {
|
||||
try {
|
||||
process.kill(serviceProcess.pid)
|
||||
} catch {}
|
||||
serviceProcess = undefined
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
// Only ever touches the process this run spawned (a prod-channel build shares its executable name
|
||||
// with the developer's installed app). Ask it to quit first so it exits the way a user's session
|
||||
// ends (Node and Chromium flush their caches on a normal exit); force-kill the tree if it lingers.
|
||||
async function killApp() {
|
||||
const pid = appPid
|
||||
if (!pid) return
|
||||
appPid = undefined
|
||||
const running = () =>
|
||||
new Promise<boolean>((done) => {
|
||||
const check =
|
||||
process.platform === "win32"
|
||||
? spawn("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { stdio: ["ignore", "pipe", "ignore"] })
|
||||
: spawn("kill", ["-0", String(pid)], { stdio: "ignore" })
|
||||
let out = ""
|
||||
check.stdout?.on("data", (chunk) => (out += chunk))
|
||||
check.on("close", (code) => done(process.platform === "win32" ? out.includes(String(pid)) : code === 0))
|
||||
})
|
||||
if (!(await running())) return
|
||||
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid)], { stdio: "ignore" })
|
||||
else process.kill(pid, "SIGTERM")
|
||||
const deadline = Date.now() + 5000
|
||||
while (Date.now() < deadline && (await running())) await sleep(100)
|
||||
if (await running()) {
|
||||
if (process.platform === "win32") spawn("taskkill", ["/PID", String(pid), "/F", "/T"], { stdio: "ignore" })
|
||||
else process.kill(pid, "SIGKILL")
|
||||
await sleep(1000)
|
||||
}
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
const CLI_VERSION = "dev"
|
||||
|
||||
@@ -75,10 +75,7 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
try {
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await copyCliToResources(
|
||||
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"),
|
||||
dest,
|
||||
)
|
||||
await copyCliToResources(join(directory, "node_modules", cli.package), dest)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -89,12 +86,23 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
|
||||
const cli = getCurrentCli()
|
||||
const directory = cli.package.replace("@opencode/", "")
|
||||
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
|
||||
await copyCliToResources(join(root, directory), dest)
|
||||
}
|
||||
|
||||
async function copyCliToResources(source: string, dest: string) {
|
||||
await copyFile(source, dest)
|
||||
// The package directory is an npm package: its package.json version is the string the executable
|
||||
// prints for --version. Writing it next to the executable spares the desktop a ~400 ms spawn of the
|
||||
// 200 MB binary on first launch.
|
||||
async function copyCliToResources(pkg: string, dest: string) {
|
||||
const cli = getCurrentCli()
|
||||
await copyFile(join(pkg, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest)
|
||||
await prepareCli(dest)
|
||||
const manifest = (await Bun.file(join(pkg, "package.json")).json()) as { version?: string }
|
||||
if (!manifest.version) throw new Error(`Bundled CLI package has no version: ${pkg}`)
|
||||
await Bun.write(versionFile(dest), manifest.version)
|
||||
}
|
||||
|
||||
export function versionFile(cli: string) {
|
||||
return join(dirname(cli), "opencode-cli.version")
|
||||
}
|
||||
|
||||
async function prepareCli(dest: string) {
|
||||
|
||||
@@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet
|
||||
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
|
||||
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
// Local renderer/server mode keeps the dev application identity.
|
||||
export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev"
|
||||
export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
|
||||
@@ -3,15 +3,21 @@ import { app } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Ipc } from "./ipc"
|
||||
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
|
||||
import { installContextMenu } from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { BackgroundService } from "./service/background-service"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { UpdaterLive } from "./updater/live"
|
||||
import { marks } from "./lifecycle/marks"
|
||||
|
||||
// Everything above has been loaded and evaluated by now; the layers start below.
|
||||
marks.bundle = Date.now()
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
yield* installContextMenu
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
app.once("will-quit", quit)
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
export {}
|
||||
// Imported first so its evaluation stamps the moment Electron handed control to this module.
|
||||
import { marks } from "./lifecycle/marks"
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { registerRendererScheme } from "./windows/scheme"
|
||||
|
||||
await import("./desktop")
|
||||
// This module stays small on purpose. Electron holds the ready event until the entry module has
|
||||
// finished, and the first window should be on screen before the rest of the main process — a few
|
||||
// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must
|
||||
// precede ready; the window is created the moment ready fires; everything else is imported after.
|
||||
configureApplication()
|
||||
if (acquireApplicationLock()) {
|
||||
registerRendererScheme()
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(() => {
|
||||
marks.ready = Date.now()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { createBrowserPane } from "../browser-pane"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
@@ -14,8 +13,16 @@ export const eventHandlers = EventRpcs.toLayer(
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const browser = createBrowserPane(storage.state)
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
// The browser pane brings the CDP driver and the full RPC client with every protocol schema;
|
||||
// load it when a renderer first opens a pane instead of at startup.
|
||||
const load = async () => {
|
||||
const { createBrowserPane } = await import("../browser-pane")
|
||||
return createBrowserPane(storage.state)
|
||||
}
|
||||
let browser: ReturnType<typeof load> | undefined
|
||||
const stop = Effect.promise(async () => {
|
||||
if (browser) await (await browser).dispose()
|
||||
})
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
@@ -27,10 +34,12 @@ export const eventHandlers = EventRpcs.toLayer(
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
|
||||
return browser.close(win, request.bindingID)
|
||||
browser ??= load()
|
||||
const pane = await browser
|
||||
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
|
||||
return pane.close(win, request.bindingID)
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
import { APP_ID, APP_NAME } from "../constants"
|
||||
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
// Runs synchronously from the entry module, before Chromium is ready: command-line switches only
|
||||
// take effect before ready, the single-instance lock is scoped to userData, and the early window
|
||||
// needs userData to find the persisted window list and state.
|
||||
export function configureApplication() {
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAppUserModelId(APP_ID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
function createTestRoot() {
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) rmSync(root, { recursive: true, force: true })
|
||||
for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"])
|
||||
mkdirSync(path.join(root, dir), { recursive: true })
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
}
|
||||
@@ -1,59 +1,18 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import http from "node:http"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import { app } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { CHANNEL } from "../constants"
|
||||
import { Effect, Path } from "effect"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const appIDs: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
export const configureApplication = Effect.fn("Application.configure")(function* () {
|
||||
const path = yield* Path.Path
|
||||
// electron-context-menu attaches to every existing and future window, so it can load once the first
|
||||
// window is up instead of holding up startup with its dependency tree.
|
||||
export const installContextMenu = Effect.gen(function* () {
|
||||
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appID)
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
|
||||
|
||||
const testRoot = yield* createTestRoot()
|
||||
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
|
||||
if (testRoot) {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
})
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
export const prepareApplicationEnvironment = Effect.gen(function* () {
|
||||
yield* loadSystemCertificates
|
||||
yield* loadProxyEnvironment
|
||||
@@ -91,29 +50,6 @@ export const loadProxyEnvironment = Effect.gen(function* () {
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
|
||||
})
|
||||
|
||||
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
|
||||
yield* Effect.forEach(
|
||||
["data", "config", "cache", "state", "desktop", "session", "documents"],
|
||||
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = path.join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = path.join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = path.join(root, "state")
|
||||
return root
|
||||
})
|
||||
|
||||
const loadSystemCertificates = Effect.try({
|
||||
try: () => {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { acquireApplicationLock, configureApplication } from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
|
||||
@@ -145,7 +144,7 @@ const runtime = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// Storage opens after configureApplication has set userData and before windows exist, so window
|
||||
// Storage opens after the entry module has set userData and before the renderer loads, so window
|
||||
// teardown can clear a window's persisted state and every renderer request finds it ready.
|
||||
const platform = Layer.mergeAll(
|
||||
DesktopLogging.layer,
|
||||
@@ -155,9 +154,6 @@ const platform = Layer.mergeAll(
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Electron scopes the single-instance lock to userData.
|
||||
yield* configureApplication()
|
||||
if (!acquireApplicationLock()) return yield* Effect.interrupt
|
||||
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
|
||||
// otherwise read as evidence of an earlier launch on a fresh install.
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
|
||||
// layer reports them with "app starting" so the startup benchmark can split the time before the
|
||||
// first log line into Electron's own initialisation, our entry, and the main bundle.
|
||||
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
|
||||
@@ -5,6 +5,7 @@ import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "../constants"
|
||||
import { marks } from "../lifecycle/marks"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
@@ -34,6 +35,7 @@ const serviceLayer = Layer.effect(
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
|
||||
marks,
|
||||
})
|
||||
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
|
||||
return Service.of({
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
|
||||
test("new consumers receive the latest reconnected service", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
const replacement = { url: "http://127.0.0.1:4200", password: "second" }
|
||||
const service = await Effect.runPromise(
|
||||
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.succeed(initial),
|
||||
reconnect: Effect.succeed(replacement),
|
||||
})
|
||||
expect(yield* state.connection).toEqual(initial)
|
||||
expect(yield* state.reconnect).toEqual(replacement)
|
||||
return yield* state.connection
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(service).toEqual(replacement)
|
||||
})
|
||||
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(initial)
|
||||
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
|
||||
test("the state is ready before the initial connect finishes and consumers wait for it", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Deferred.await(gate).pipe(Effect.as(initial)),
|
||||
reconnect: Effect.succeed(initial),
|
||||
})
|
||||
const consumer = yield* Effect.forkChild(state.connection)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(consumer.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(consumer)).toEqual(initial)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("a failed initial connect fails consumers instead of hanging them", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.fail(new Error("service unavailable")),
|
||||
reconnect: Effect.succeed({ url: "http://127.0.0.1:4100", password: "later" }),
|
||||
})
|
||||
return yield* state.connection.pipe(Effect.exit)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import { Deferred, Effect, Exit, Ref } from "effect"
|
||||
import type { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
// The initial connect runs in the background: windows are created as soon as the main process
|
||||
// is up, and the renderer loads its bundle while the CLI service is being resolved. Consumers
|
||||
// await the first outcome through `connection`, so nothing observes the service before it exists.
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown> | undefined>(undefined)
|
||||
const first = yield* Deferred.make<void>()
|
||||
yield* options.initial.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Ref.set(current, exit)),
|
||||
Effect.ensuring(Deferred.succeed(first, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return {
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
connection: Deferred.await(first).pipe(
|
||||
Effect.flatMap(() => Ref.get(current)),
|
||||
Effect.flatMap((exit) => exit ?? Exit.die(new Error("background service connect did not resolve"))),
|
||||
Effect.orDie,
|
||||
),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
|
||||
import installer from "../../../../../install?raw"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -79,11 +81,46 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
|
||||
? path.join(process.resourcesPath, executableName())
|
||||
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
|
||||
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
const version = yield* bundledVersion(bundled)
|
||||
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
|
||||
return { version, binary, command: [binary] }
|
||||
})
|
||||
|
||||
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary (and
|
||||
// several seconds on the first launch after an update, while the antivirus scans it). The build
|
||||
// writes the version next to the executable, so a packaged app never spawns; the per-identity cache
|
||||
// covers executables that arrived without that file.
|
||||
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const shipped = yield* fs
|
||||
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
|
||||
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
|
||||
if (shipped) {
|
||||
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
|
||||
return shipped
|
||||
}
|
||||
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
|
||||
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
|
||||
const store = getStore()
|
||||
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
|
||||
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
|
||||
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
|
||||
return cached.version
|
||||
}
|
||||
const version = parseCliVersion(yield* run(bundled, ["--version"]))
|
||||
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
|
||||
return version
|
||||
})
|
||||
|
||||
type VersionCache = { path: string; identity: string; version: string }
|
||||
|
||||
function isVersionCache(value: unknown): value is VersionCache {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const cache = value as Record<string, unknown>
|
||||
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
|
||||
}
|
||||
|
||||
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
@@ -17,17 +17,29 @@ describe("draft store", () => {
|
||||
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
|
||||
})
|
||||
|
||||
test("stores blobs by content hash and collects unreferenced ones on open", () => {
|
||||
test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const first = createDraftStore(database.db, { delay: 1_000 })
|
||||
let clock = 0
|
||||
const first = createDraftStore(database.db, { delay: 1_000, now: () => clock })
|
||||
const used = first.putBlob(new Uint8Array([1, 2, 3]))
|
||||
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
|
||||
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
|
||||
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
|
||||
first.flush()
|
||||
const second = createDraftStore(database.db, { delay: 1_000 })
|
||||
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(second.getBlob(unused)).toBeNull()
|
||||
first.close()
|
||||
// The startup collection runs off the window's critical path and keeps the usual grace, so a
|
||||
// blob from a session that ended within the grace period is still there...
|
||||
const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6]))
|
||||
second.close()
|
||||
// ...and gone once the grace has passed.
|
||||
clock = blobGrace + 1
|
||||
const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(third.getBlob(unused)).toBeNull()
|
||||
third.close()
|
||||
})
|
||||
|
||||
test("collects a retired chunk only once it is unreferenced and past the grace period", () => {
|
||||
|
||||
@@ -27,11 +27,14 @@ const referenced = (value: unknown) => sql`
|
||||
|
||||
export function createDraftStore(
|
||||
db: Database,
|
||||
input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
) {
|
||||
const now = input.now ?? Date.now
|
||||
// Nothing outside this process can hold a blob id at startup, so no grace applies.
|
||||
collectBlobs(db, Infinity)
|
||||
// Orphans left by an earlier session are collected once the window is up rather than before it:
|
||||
// the scan walks every stored document, and the usual grace keeps anything a renderer has
|
||||
// uploaded in the meantime.
|
||||
const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000)
|
||||
startup.unref()
|
||||
let collected = now()
|
||||
let orphans = false
|
||||
const byKey = eq(document.key, sql.placeholder("key"))
|
||||
@@ -104,7 +107,10 @@ export function createDraftStore(
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
close() {
|
||||
clearTimeout(startup)
|
||||
writer.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const BACKGROUND_COLOR_KEY = "backgroundColor"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { createSettingsStore } from "./store"
|
||||
|
||||
const dir = () => mkdtempSync(path.join(tmpdir(), "opencode-settings-"))
|
||||
|
||||
describe("settings store", () => {
|
||||
test("reads the tab-indented file electron-store wrote and rewrites it in the same shape", () => {
|
||||
const file = path.join(dir(), "opencode.settings")
|
||||
writeFileSync(file, JSON.stringify({ windowIds: ["a"], backgroundColor: "#121212" }, null, "\t"))
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("windowIds")).toEqual(["a"])
|
||||
store.set("backgroundColor", "#ffffff")
|
||||
expect(readFileSync(file, "utf8")).toBe(
|
||||
JSON.stringify({ windowIds: ["a"], backgroundColor: "#ffffff" }, null, "\t"),
|
||||
)
|
||||
expect(createSettingsStore(file).get("backgroundColor")).toBe("#ffffff")
|
||||
})
|
||||
|
||||
test("starts empty without a file and creates it on the first write", () => {
|
||||
const file = path.join(dir(), "nested", "opencode.updater")
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("ready")).toBeUndefined()
|
||||
expect(existsSync(file)).toBe(false)
|
||||
store.set("ready", { version: "1.0.0" })
|
||||
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ ready: { version: "1.0.0" } })
|
||||
store.delete("ready")
|
||||
expect(store.get("ready")).toBeUndefined()
|
||||
expect(readFileSync(file, "utf8")).toBe("{}")
|
||||
})
|
||||
|
||||
test("sets an unreadable file aside instead of failing", () => {
|
||||
const file = path.join(dir(), "opencode.settings")
|
||||
writeFileSync(file, "{ not json")
|
||||
const store = createSettingsStore(file)
|
||||
expect(store.get("anything")).toBeUndefined()
|
||||
expect(existsSync(`${file}.corrupt`)).toBe(true)
|
||||
store.set("firstLaunchOnboardingComplete", true)
|
||||
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ firstLaunchOnboardingComplete: true })
|
||||
})
|
||||
|
||||
test("rejects undefined like electron-store did", () => {
|
||||
const store = createSettingsStore(path.join(dir(), "opencode.settings"))
|
||||
expect(() => store.set("key", undefined)).toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,62 @@
|
||||
import Store from "electron-store"
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import electron from "electron"
|
||||
|
||||
import { SETTINGS_STORE } from "./keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
const cache = new Map<string, SettingsStore>()
|
||||
|
||||
export type SettingsStore = ReturnType<typeof createSettingsStore>
|
||||
|
||||
// Main-process settings only (onboarding, default server, window list, appearance, updater).
|
||||
// These are read synchronously before the storage layer exists and written on user action, so
|
||||
// electron-store's synchronous file write is acceptable here. Renderer state goes through
|
||||
// DesktopStorage instead.
|
||||
// These are read synchronously before the storage layer exists and written on user action, so a
|
||||
// synchronous whole-file write is acceptable here. Renderer state goes through DesktopStorage.
|
||||
//
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
// (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
// The file is the tab-indented JSON object electron-store used to write, so existing profiles
|
||||
// (and older builds) read the same data.
|
||||
//
|
||||
// We cannot instantiate the store at module load time because module import hoisting causes this
|
||||
// to run before app.setPath("userData", ...) has executed, which would result in files being
|
||||
// written to the default directory (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs
|
||||
// good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({
|
||||
name,
|
||||
cwd: electron.app.getPath("userData"),
|
||||
fileExtension: "",
|
||||
accessPropertiesByDotNotation: false,
|
||||
})
|
||||
const next = createSettingsStore(path.join(electron.app.getPath("userData"), name))
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function createSettingsStore(file: string) {
|
||||
let data = read(file)
|
||||
const write = () => {
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
writeFileSync(`${file}.tmp`, JSON.stringify(data, null, "\t"))
|
||||
renameSync(`${file}.tmp`, file)
|
||||
}
|
||||
return {
|
||||
path: file,
|
||||
get: (key: string): unknown => data[key],
|
||||
set: (key: string, value: unknown) => {
|
||||
if (value === undefined) throw new TypeError(`Use \`delete()\` to clear values: ${key}`)
|
||||
data = { ...data, [key]: value }
|
||||
write()
|
||||
},
|
||||
delete: (key: string) => {
|
||||
data = Object.fromEntries(Object.entries(data).filter(([item]) => item !== key))
|
||||
write()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A missing file is an empty store. An unreadable one is set aside instead of blocking startup.
|
||||
function read(file: string): Record<string, unknown> {
|
||||
if (!existsSync(file)) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"))
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
||||
} catch {
|
||||
renameSync(file, `${file}.corrupt`)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
|
||||
import type { Path } from "effect"
|
||||
import { type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
@@ -9,16 +6,10 @@ import { emitIpcEvent } from "../ipc-events"
|
||||
import type { DesktopPaths } from "../paths"
|
||||
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay, tone } from "./defaults"
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
const titlebarHeight = 44
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
let backgroundColor: string | undefined
|
||||
@@ -28,7 +19,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
|
||||
return {
|
||||
title: "OpenCode",
|
||||
icon: iconPath(path, paths),
|
||||
backgroundColor: getBackgroundColor() ?? oc2Background[mode],
|
||||
backgroundColor: backgroundColor ?? storedBackgroundColor(),
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -58,8 +49,10 @@ export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
// The renderer reports its theme background on every boot; electron-store rewrites and fsyncs the
|
||||
// settings file on each set, so only persist a change.
|
||||
if (getBackgroundColor() !== color) getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
backgroundColor = color
|
||||
getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.setBackgroundColor(color)
|
||||
if (process.platform === "darwin") win.invalidateShadow()
|
||||
@@ -137,17 +130,8 @@ function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
return titlebarOverlay(theme.mode ?? tone(), zoom)
|
||||
}
|
||||
|
||||
function clampZoom(value: number) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode/ui/theme/types"
|
||||
import { nativeTheme } from "electron"
|
||||
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
|
||||
import { BACKGROUND_COLOR_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
// Frame defaults shared by the early window (created on ready, before the renderer exists) and the
|
||||
// full window setup in appearance.ts, so both draw the same frame.
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
export const titlebarHeight = 44
|
||||
|
||||
export function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
|
||||
// a window shown before the renderer paints already has the right background.
|
||||
export function storedBackgroundColor() {
|
||||
const stored = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return typeof stored === "string" ? stored : oc2Background[tone()]
|
||||
}
|
||||
|
||||
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
|
||||
|
||||
let pending: EarlyWindow | undefined
|
||||
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
|
||||
}
|
||||
|
||||
// Creates and shows the first restored window the moment Electron is ready, before the rest of the
|
||||
// main process has loaded. The frame options mirror windowAppearance(); the persisted background
|
||||
// colour stands in for the theme until the renderer applies it, so the window is on screen while the
|
||||
// bundle, the layers and the renderer boot. restoreWindows() adopts it through takeEarlyWindow().
|
||||
export function createEarlyWindow() {
|
||||
const ids = getStore().get(WINDOW_IDS_KEY)
|
||||
const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID()
|
||||
const root = path.dirname(fileURLToPath(import.meta.url))
|
||||
const file = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = resolveWindowState(readWindowState(file), { width: 1280, height: 800 }, displays)
|
||||
const icons = app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(root, "../../resources/icons")
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: path.join(icons, `icon.${process.platform === "win32" ? "ico" : "png"}`),
|
||||
backgroundColor: storedBackgroundColor(),
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, trafficLightPosition: { x: 14, y: 14 } } : {}),
|
||||
...(process.platform === "win32" ? { frame: false, titleBarStyle: "hidden" as const, titleBarOverlay: titlebarOverlay() } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(root, "../preload/index.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
manageWindowState(win, file, state, displays)
|
||||
// Closing the only window before the rest of the app has adopted it is a quit.
|
||||
win.once("closed", () => {
|
||||
if (pending?.win !== win) return
|
||||
pending = undefined
|
||||
app.quit()
|
||||
})
|
||||
pending = { id, win, state, shownAt: Date.now() }
|
||||
}
|
||||
|
||||
export function takeEarlyWindow() {
|
||||
const taken = pending
|
||||
pending = undefined
|
||||
return taken
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { openExternalURL } from "../files"
|
||||
import { scoped } from "../native/logging"
|
||||
@@ -25,9 +24,16 @@ import {
|
||||
import { loadWindow, registerRendererProtocol } from "./protocol"
|
||||
import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { takeEarlyWindow, type EarlyWindow } from "./early"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile } from "./window-state"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
|
||||
const themeReady = new WeakMap<BrowserWindow, () => void>()
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds,
|
||||
}
|
||||
const registry = createWindowRegistry<BrowserWindow>({
|
||||
read: () => getStore().get(WINDOW_IDS_KEY),
|
||||
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
|
||||
@@ -83,47 +89,62 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const wireWindowRecovery = yield* makeWindowRecovery
|
||||
|
||||
const restore = () => {
|
||||
// The entry module created and showed the first restored window on ready; it is adopted here,
|
||||
// before any renderer loads, so the user never waited for the layers to see a window.
|
||||
const early = takeEarlyWindow()
|
||||
const usable = early && !early.win.isDestroyed() ? early : undefined
|
||||
const ids = registry.persisted()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => create(id))
|
||||
const list = ids.length ? ids : [usable?.id ?? randomUUID()]
|
||||
if (usable && !list.includes(usable.id)) usable.win.destroy()
|
||||
return list.map((id) => create(id, usable?.id === id ? usable : undefined))
|
||||
}
|
||||
|
||||
const create = (id: string = randomUUID()) => {
|
||||
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
|
||||
const create = (id: string = randomUUID(), early?: EarlyWindow) => {
|
||||
const stateFile = path.join(app.getPath("userData"), windowStateFile(id))
|
||||
const state = early?.state ?? resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays)
|
||||
const appearance = windowAppearance(path, paths)
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
const win =
|
||||
early?.win ??
|
||||
new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
state.manage(win)
|
||||
if (!early) manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
let revealed = false
|
||||
let revealed = !!early
|
||||
const focusForTests = () => {
|
||||
if (app.isPackaged || process.env.OPENCODE_TEST_ONBOARDING !== "1") return
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
if (early) {
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id, shownAt: early.shownAt }))
|
||||
}
|
||||
const reveal = () => {
|
||||
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
|
||||
revealed = true
|
||||
win.show()
|
||||
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
|
||||
if (process.platform === "darwin") app.focus({ steal: true })
|
||||
win.focus()
|
||||
}
|
||||
focusForTests()
|
||||
runFork(Effect.logInfo("main window visible", { window: id }))
|
||||
}
|
||||
const ready = () => {
|
||||
@@ -161,10 +182,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
return { create, restore }
|
||||
})
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
return `window-state-${safeWindowID(id)}.json`
|
||||
}
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
|
||||
// namespace the renderer persists this window's tabs under.
|
||||
function windowDataFile(id: string) {
|
||||
|
||||
@@ -5,21 +5,7 @@ import { Effect, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user