mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 17:36:27 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea18eb0d29 | ||
|
|
1768e76276 | ||
|
|
76e57346ef | ||
|
|
002285b0d6 | ||
|
|
5c3f2ddf8c | ||
|
|
1382cebe10 | ||
|
|
0732cdd8e1 | ||
|
|
2bf6a9813a | ||
|
|
f094a10645 | ||
|
|
89f1943d2d | ||
|
|
fc4f319676 | ||
|
|
b51fdb331c | ||
|
|
8a1a6b8fb4 | ||
|
|
1a2b985777 | ||
|
|
00067d23a0 | ||
|
|
3ebc4b01b9 | ||
|
|
17f362e0fe | ||
|
|
5b83c15fb5 | ||
|
|
d86c3041c9 | ||
|
|
3cf197a309 |
@@ -63,6 +63,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -357,6 +358,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -415,6 +417,7 @@
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
@@ -422,6 +425,8 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -432,12 +437,14 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "8.2.2",
|
||||
@@ -605,6 +612,21 @@
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode-ai/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/posts": {
|
||||
"name": "@opencode-ai/posts",
|
||||
"dependencies": {
|
||||
@@ -1824,6 +1846,16 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
@@ -2144,6 +2176,8 @@
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
|
||||
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
|
||||
|
||||
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
@@ -2208,6 +2242,8 @@
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="],
|
||||
|
||||
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
||||
@@ -2218,6 +2254,8 @@
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="],
|
||||
@@ -2490,6 +2528,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@paulirish/trace_engine": ["@paulirish/trace_engine@0.0.65", "", { "dependencies": { "legacy-javascript": "latest", "third-party-web": "latest" } }, "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg=="],
|
||||
|
||||
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q=="],
|
||||
|
||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
||||
@@ -2546,6 +2586,8 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="],
|
||||
|
||||
"@radix-ui/colors": ["@radix-ui/colors@1.0.1", "", {}, "sha512-xySw8f0ZVsAEP+e7iLl3EvcBXX7gsIlC1Zso/sPBW9gIWerBTgz6axrjU+MZ39wD+WFi5h5zdWpsg3+hwt2Qsg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw=="],
|
||||
@@ -2744,10 +2786,18 @@
|
||||
|
||||
"@sentry/feedback": ["@sentry/feedback@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0" } }, "sha512-4+zMAmn1DcbYBtFE0pWt6zo8vWoBHTuP/UhtCMTCoGB4sVYvmwRxv9Hc9OpRHSzE9kSl8beD0zKFxuoVHizKQQ=="],
|
||||
|
||||
"@sentry/node": ["@sentry/node@10.73.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/node-core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "@sentry/server-utils": "10.73.0", "import-in-the-middle": "^3.0.0" } }, "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg=="],
|
||||
|
||||
"@sentry/node-core": ["@sentry/node-core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g=="],
|
||||
|
||||
"@sentry/opentelemetry": ["@sentry/opentelemetry@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw=="],
|
||||
|
||||
"@sentry/replay": ["@sentry/replay@10.71.0", "", { "dependencies": { "@sentry/browser-utils": "10.71.0", "@sentry/core": "10.71.0" } }, "sha512-ytEgVg7isvavQL6hgsgYeuSCkcA3EyOKm9lMLQOZTLkiUCtFT/ItWwGNhzS46vQ6wsTv3Uc0Y1JlcJHSFKe/Kg=="],
|
||||
|
||||
"@sentry/replay-canvas": ["@sentry/replay-canvas@10.71.0", "", { "dependencies": { "@sentry/core": "10.71.0", "@sentry/replay": "10.71.0" } }, "sha512-YTqesxBh9arExI49LrTm4Y2/xPX40q2GWi0gWRw6lNfYtyNz7/ZfHi8/1N6JEc8dldTslqFhII6ZFecUyU9iaA=="],
|
||||
|
||||
"@sentry/server-utils": ["@sentry/server-utils@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" } }, "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA=="],
|
||||
|
||||
"@sentry/solid": ["@sentry/solid@10.71.0", "", { "dependencies": { "@sentry/browser": "10.71.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.71.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-/vVy9GmB/RejmomWEMZ+P3xlwrhfpIYJ3F0+UcTE836uuH92r8CSnmucdGQqke8c/6ullQFjZrLd/dTDPXj1aA=="],
|
||||
|
||||
"@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="],
|
||||
@@ -3314,6 +3364,8 @@
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
@@ -3542,10 +3594,16 @@
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="],
|
||||
|
||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||
|
||||
"ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="],
|
||||
|
||||
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
@@ -3602,6 +3660,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
@@ -3638,6 +3698,8 @@
|
||||
|
||||
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
|
||||
|
||||
"csp_evaluator": ["csp_evaluator@1.1.8", "", {}, "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
|
||||
@@ -3742,6 +3804,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
@@ -3790,6 +3854,8 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="],
|
||||
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"diacritics": ["diacritics@1.3.0", "", {}, "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA=="],
|
||||
@@ -3894,6 +3960,8 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
@@ -3938,7 +4006,7 @@
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
@@ -4240,6 +4308,8 @@
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"http-link-header": ["http-link-header@1.1.4", "", {}, "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
||||
@@ -4264,8 +4334,12 @@
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
"image-ssim": ["image-ssim@0.2.0", "", {}, "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg=="],
|
||||
|
||||
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"import-in-the-middle": ["import-in-the-middle@3.4.0", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -4284,6 +4358,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||
|
||||
"ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -4402,12 +4478,16 @@
|
||||
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
|
||||
|
||||
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "js-beautify": "js/bin/js-beautify.js", "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
|
||||
|
||||
"js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="],
|
||||
|
||||
"js-library-detector": ["js-library-detector@6.7.0", "", {}, "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||
@@ -4480,8 +4560,16 @@
|
||||
|
||||
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||
|
||||
"legacy-javascript": ["legacy-javascript@0.0.1", "", {}, "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg=="],
|
||||
|
||||
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||
|
||||
"lighthouse": ["lighthouse@13.4.1", "", { "dependencies": { "@paulirish/trace_engine": "0.0.65", "@sentry/node": "^10.0.0", "axe-core": "^4.12.1", "chrome-launcher": "^1.2.1", "configstore": "^7.0.0", "csp_evaluator": "1.1.8", "devtools-protocol": "0.0.1663043", "enquirer": "^2.3.6", "http-link-header": "^1.1.1", "intl-messageformat": "^10.5.3", "jpeg-js": "^0.4.4", "js-library-detector": "^6.7.0", "lighthouse-logger": "^2.0.2", "lighthouse-stack-packs": "1.12.3", "lodash-es": "^4.17.21", "lookup-closest-locale": "6.2.0", "open": "^8.4.0", "puppeteer-core": "^25.3.0", "robots-parser": "^3.0.1", "speedline-core": "^1.4.3", "third-party-web": "^0.29.2", "tldts-icann": "^7.4.9", "web-features": "^3.34.0", "ws": "^7.0.0", "yargs": "^17.3.1", "yargs-parser": "^21.0.0" }, "bin": { "lighthouse": "cli/index.js", "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js", "chrome-debug": "core/scripts/manual-chrome-launcher.js" } }, "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA=="],
|
||||
|
||||
"lighthouse-logger": ["lighthouse-logger@2.0.2", "", { "dependencies": { "debug": "^4.4.1", "marky": "^1.2.2" } }, "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg=="],
|
||||
|
||||
"lighthouse-stack-packs": ["lighthouse-stack-packs@1.12.3", "", {}, "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
@@ -4528,6 +4616,8 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lookup-closest-locale": ["lookup-closest-locale@6.2.0", "", {}, "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
@@ -4560,6 +4650,8 @@
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||
|
||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
@@ -4720,8 +4812,14 @@
|
||||
|
||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="],
|
||||
|
||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
@@ -5032,6 +5130,8 @@
|
||||
|
||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@25.9.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.3" } }, "sha512-U61rCwSMha62CA/Opy6tCx2Fx+ck7ouiKnbpEApzSoLYMoEu9F71nuFpHL55vmIt33/GYm6eKZVhH2ev0nAIeg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
@@ -5158,6 +5258,8 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||
|
||||
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
||||
|
||||
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||
@@ -5192,6 +5294,8 @@
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
|
||||
@@ -5350,6 +5454,8 @@
|
||||
|
||||
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
||||
|
||||
"speedline-core": ["speedline-core@1.4.3", "", { "dependencies": { "@types/node": "*", "image-ssim": "^0.2.0", "jpeg-js": "^0.4.1" } }, "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
@@ -5484,6 +5590,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"third-party-web": ["third-party-web@0.29.2", "", {}, "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g=="],
|
||||
|
||||
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
@@ -5506,6 +5614,10 @@
|
||||
|
||||
"titleize": ["titleize@4.0.0", "", {}, "sha512-ZgUJ1K83rhdu7uh7EHAC2BgY5DzoX8V5rTvoWI4vFysggi6YjLe5gUXABPWAU7VkvGP7P/0YiWq+dcPeYDsf1g=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
|
||||
|
||||
"tldts-icann": ["tldts-icann@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" } }, "sha512-1p+NDJ7FUYCliESmsQl9EW5Um8JIyFheiy6Y6ZoHho27d+TLAGFa0gMf1VOVy02vNY1aQo6xeKiaGj45+7P+PA=="],
|
||||
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||
@@ -5568,6 +5680,8 @@
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
"typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="],
|
||||
|
||||
"typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
|
||||
@@ -5728,6 +5842,8 @@
|
||||
|
||||
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
|
||||
|
||||
"web-features": ["web-features@3.37.0", "", {}, "sha512-47x5zchtpsvvH/EEQ0h3d3E9kZyFxFyk6LkmYHXDZjLF0RAa3OV2s8dMRqphp/PTxxrd/iZwz37dte6xWgV7mQ=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||
@@ -5736,6 +5852,8 @@
|
||||
|
||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
@@ -5812,6 +5930,8 @@
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
@@ -6284,6 +6404,12 @@
|
||||
|
||||
"@opencode-ai/www/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="],
|
||||
|
||||
"@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="],
|
||||
|
||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
@@ -6316,6 +6442,8 @@
|
||||
|
||||
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@sentry/bundler-plugins/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
@@ -6326,6 +6454,18 @@
|
||||
|
||||
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="],
|
||||
|
||||
"@sentry/node/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/node-core/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/opentelemetry/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@sentry/server-utils/@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
@@ -6428,6 +6568,10 @@
|
||||
|
||||
"builder-util/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=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
@@ -6446,6 +6590,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
|
||||
@@ -6490,6 +6636,8 @@
|
||||
|
||||
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6534,6 +6682,8 @@
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6542,10 +6692,16 @@
|
||||
|
||||
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
|
||||
|
||||
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -6610,6 +6766,10 @@
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="],
|
||||
|
||||
"puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6678,6 +6838,8 @@
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
||||
@@ -7194,6 +7356,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opentelemetry/instrumentation/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
@@ -7202,10 +7366,20 @@
|
||||
|
||||
"@pierre/trees/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="],
|
||||
|
||||
"@sentry/node/@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="],
|
||||
@@ -7370,6 +7544,8 @@
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7406,6 +7582,8 @@
|
||||
|
||||
"electron/@electron/get/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
@@ -7428,6 +7606,12 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
@@ -8172,6 +8356,8 @@
|
||||
|
||||
"@opencode-ai/www/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
|
||||
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
|
||||
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
|
||||
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
|
||||
"x86_64-linux": "sha256-7NBjLAaZRbirLuBJdQO9iwlonqUFKOkU8u6rh/e8Ij0=",
|
||||
"aarch64-linux": "sha256-877mqEw+JGTTftYSWvHOsKnFRQ0B5umxY5xW31Rs+ms=",
|
||||
"aarch64-darwin": "sha256-eaWQZfyQMefy5kn+Q9dAzOnXcwjwx7EEtDuzpocgHOQ=",
|
||||
"x86_64-darwin": "sha256-mg+Sr8h7d2dmrnOfjiX/ktmA3wuBg/iGZv9ooFvlERc="
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -1,6 +1,6 @@
|
||||
# @opencode-ai/ai
|
||||
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
Schema-first language model and image-generation APIs built with Effect.
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
@@ -255,7 +255,7 @@ over the same implementation, including the legacy live `requests` array. New te
|
||||
|
||||
## Provider compaction
|
||||
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
|
||||
|
||||
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
|
||||
|
||||
@@ -298,7 +298,7 @@ result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
@@ -372,9 +372,7 @@ providerOptions: {
|
||||
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
|
||||
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
|
||||
|
||||
### Ownership and verification
|
||||
|
||||
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
|
||||
### Recording tests
|
||||
|
||||
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
|
||||
|
||||
@@ -393,7 +391,7 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tool definitions, system instructions, and the active conversation. The rolling final-message boundary advances on every request so recent conversation prefixes remain reusable during tool loops.
|
||||
|
||||
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||
|
||||
@@ -474,20 +472,20 @@ const fireworks = Fireworks.configure({ apiKey }).model("accounts/fireworks/mode
|
||||
|
||||
The former `OpenAICompatible.baseten`, `.cerebras`, `.deepinfra`, `.deepseek`, `.fireworks`, `.groq`, and `.togetherai` presets are replaced by the top-level `Baseten`, `Cerebras`, `DeepInfra`, `DeepSeek`, `Fireworks`, `Groq`, and `TogetherAI` exports. Use `CloudflareAIGateway` and `CloudflareWorkersAI` directly; each has its own module. `OpenAICompatible` configures generic endpoints with an explicit `baseURL`.
|
||||
|
||||
### Package-like entrypoints
|
||||
### Provider entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
|
||||
Provider modules are available through dedicated exports from `@opencode-ai/ai`. Each LLM entrypoint exports `model(modelID, settings)`, where `settings` contains provider configuration plus common `headers` and `body` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
headers: { "x-application": "example" },
|
||||
})
|
||||
```
|
||||
|
||||
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
||||
APIs have separate entrypoints:
|
||||
|
||||
- `@opencode-ai/ai/providers/openai/chat`
|
||||
- `@opencode-ai/ai/providers/openai/responses`
|
||||
@@ -528,9 +526,7 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
|
||||
Every named LLM provider listed above also exports `model(modelID, settings)` from its own package entrypoint. The extracted providers are available at:
|
||||
Additional provider entrypoints include:
|
||||
|
||||
- `@opencode-ai/ai/providers/baseten`
|
||||
- `@opencode-ai/ai/providers/deepseek`
|
||||
@@ -538,8 +534,6 @@ Every named LLM provider listed above also exports `model(modelID, settings)` fr
|
||||
- `@opencode-ai/ai/providers/cloudflare-ai-gateway`
|
||||
- `@opencode-ai/ai/providers/cloudflare-workers-ai`
|
||||
|
||||
Core resolves known compatible catalog providers through their dedicated entrypoints, including the `fireworks-ai` catalog ID through `providers/fireworks`.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Request options in order of stability:
|
||||
@@ -565,7 +559,7 @@ LLM.request({
|
||||
|
||||
## Routes
|
||||
|
||||
Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
|
||||
Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports receive the route's endpoint and auth when preparing requests. Unsupported request shapes fail during protocol lowering.
|
||||
|
||||
## Effect
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface WebSocketChannelExchange {
|
||||
readonly connect: {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
/** Provider-safe connection age after which Core should rotate before sending. */
|
||||
/** Provider-safe connection age after which the channel executor should reconnect before sending. */
|
||||
readonly rotateAfterMs?: number
|
||||
}
|
||||
readonly fallback: () => Stream.Stream<string, AIError>
|
||||
|
||||
@@ -34,7 +34,7 @@ const observationFrame = (observation: ChannelObservation) => {
|
||||
|
||||
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
|
||||
// This channel fixture supports sequential test traffic.
|
||||
const makeChannel = Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
let connection: WebSocketConnection | undefined
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,16 @@ export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export type {
|
||||
BrowserPaneCommand,
|
||||
BrowserPaneEndpoint,
|
||||
BrowserPaneEvent,
|
||||
BrowserPaneLayout,
|
||||
BrowserPanePlatform,
|
||||
BrowserPaneRegistration,
|
||||
BrowserPaneState,
|
||||
BrowserPaneTarget,
|
||||
} from "./runtime/platform/browser-pane"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
|
||||
@@ -75,9 +75,14 @@ export function createNewSessionWorkspaceController(input: {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
|
||||
// behaves like a plain read, which holds the transition that opens the New Session tab until
|
||||
// the worktree list returns.
|
||||
const worktreesLoaded = () => worktrees.state === "ready" || worktrees.state === "refreshing"
|
||||
const worktreeItems = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
if (!worktreesLoaded()) return project.worktrees
|
||||
const loaded = worktrees.latest
|
||||
return loaded?.projectID === project.id ? loaded.items : project.worktrees
|
||||
})
|
||||
@@ -110,10 +115,11 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = currentProject()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, worktree))
|
||||
? worktree
|
||||
: undefined
|
||||
if (isWorkspaceSelection(project, worktree)) return worktree
|
||||
// A saved choice may only exist in the server inventory. Keep it until the list can confirm it,
|
||||
// otherwise the selector falls back to Local while loading and a submit would target the wrong directory.
|
||||
if (!worktreesLoaded()) return worktree
|
||||
return worktreeDirectories().some((item) => sameDirectory(item, worktree)) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = currentProject()
|
||||
@@ -140,12 +146,19 @@ export function createNewSessionWorkspaceController(input: {
|
||||
.catch(() => ({ directory, search, data: [] })),
|
||||
)
|
||||
createEffect(() => {
|
||||
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
|
||||
() => undefined,
|
||||
)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
void Promise.all([
|
||||
data.location.syncInfo({ directory: sdk().directory }),
|
||||
data.project.sync(),
|
||||
data.location.vcs.sync({ directory: sdk().directory }),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
// Only the selected worktree feeds the branch label. Syncing every worktree in the inventory boots
|
||||
// each one on the server, which then emits `agent.updated` and makes the client run the full
|
||||
// catalog fan-out for every directory.
|
||||
createEffect(() => {
|
||||
const selection = value()
|
||||
if (selection === "main" || selection === "create") return
|
||||
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
@@ -169,12 +182,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
workspace: createMemo(() => {
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return (
|
||||
current === "create" ||
|
||||
(!!project &&
|
||||
(isWorkspaceDirectory(project, current) ||
|
||||
worktreeDirectories().some((item) => sameDirectory(item, current))))
|
||||
)
|
||||
if (current === "create") return true
|
||||
if (current === "main" || !project) return false
|
||||
if (isWorkspaceDirectory(project, current) || !worktreesLoaded()) return true
|
||||
return worktreeDirectories().some((item) => sameDirectory(item, current))
|
||||
}),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
|
||||
@@ -667,6 +667,8 @@ export const dict = {
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.browser": "Browser",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
@@ -823,6 +825,10 @@ export const dict = {
|
||||
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
|
||||
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
|
||||
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
"titlebar.channel.local": "Local",
|
||||
@@ -1049,6 +1055,8 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
"settings.general.row.showSearch.title": "Command palette",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
|
||||
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
|
||||
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
|
||||
export type BrowserPaneLayout = {
|
||||
tabID: Browser.TabID
|
||||
visible: boolean
|
||||
bounds?: { x: number; y: number; width: number; height: number }
|
||||
background?: readonly [number, number, number, number]
|
||||
radius?: number
|
||||
}
|
||||
|
||||
export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
close(): void
|
||||
}
|
||||
|
||||
export type BrowserPanePlatform = {
|
||||
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { WslServersPlatform } from "@/servers/wsl/types"
|
||||
import type { UpdaterPlatform } from "@/shell/updates/types"
|
||||
import type { DraftStore } from "@/runtime/persistence/drafts"
|
||||
import type { BrowserPanePlatform } from "./browser-pane"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -118,6 +119,9 @@ type PlatformBase = {
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
type Server = ReturnType<typeof useServer>
|
||||
|
||||
export type BrowserAttachment = {
|
||||
registration?: BrowserPaneRegistration
|
||||
browser: BrowserPaneState
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Live = {
|
||||
server: Server
|
||||
sessionID: string
|
||||
/** Shell tab that owns this attachment once seen; it may route to a child session later. */
|
||||
tab?: string
|
||||
registration?: BrowserPaneRegistration
|
||||
retry?: ReturnType<typeof setTimeout>
|
||||
attempts: number
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
|
||||
// browser survive visiting Settings or another tab and close when the session tab or the setting does.
|
||||
export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } = createSimpleContext({
|
||||
name: "BrowserAttachments",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
)
|
||||
const close = (id: string) => {
|
||||
live.get(id)?.dispose()
|
||||
live.delete(id)
|
||||
setStore(id, undefined)
|
||||
}
|
||||
createEffect(() => {
|
||||
const on = enabled()
|
||||
const tabs = shellTabs.store
|
||||
// The store's keys mirror `live`, and reading them keeps this effect subscribed to new attachments.
|
||||
Object.keys(store).forEach((id) => {
|
||||
const entry = live.get(id)
|
||||
if (!entry) return
|
||||
// Tabs hydrate asynchronously, so the owner is learned when first seen rather than required up
|
||||
// front. A tab keeps owning the attachment while it exists, even after routing back to its parent.
|
||||
const current = findSessionTab(tabs, entry.server.key, entry.sessionID)
|
||||
if (current) entry.tab = tabKey(current)
|
||||
const owned = entry.tab === undefined || tabs.some((tab) => tabKey(tab) === entry.tab)
|
||||
if (on && owned && !entry.server.health?.incompatible) return
|
||||
close(id)
|
||||
})
|
||||
})
|
||||
onCleanup(() => Array.from(live.keys()).forEach(close))
|
||||
|
||||
return {
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined }
|
||||
live.set(id, entry)
|
||||
setStore(id, { browser: null })
|
||||
const register = () => {
|
||||
if (entry.registration || live.get(id) !== entry) return
|
||||
// The server's shared transport follows a restarted sidecar's port whether or not any route
|
||||
// for this session is mounted; the connection captured at attach time may predate it.
|
||||
const endpoint = { ...server.conn.http, url: server.ctx.sdk.url }
|
||||
const registration = pane.register({ sessionID, endpoint }, (event) => {
|
||||
if (live.get(id) !== entry) return
|
||||
if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID))
|
||||
if (event.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
return close(id)
|
||||
}
|
||||
if (event.error === "browser.pane.replaced") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
return setStore(id, {
|
||||
registration: undefined,
|
||||
browser: null,
|
||||
error: language.t("session.browser.replaced"),
|
||||
})
|
||||
}
|
||||
// The desktop dropped the attachment (server restart, attach race). Re-register so the
|
||||
// agent's browser tool comes back without a reload.
|
||||
if (event.error === "browser.pane.registration.closed") {
|
||||
registration.close()
|
||||
entry.registration = undefined
|
||||
setStore(id, { registration: undefined, browser: null, error: undefined })
|
||||
entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++))
|
||||
return
|
||||
}
|
||||
if (event.state) entry.attempts = 0
|
||||
batch(() => {
|
||||
setStore(id, "browser", reconcile(event.state))
|
||||
setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined)
|
||||
})
|
||||
})
|
||||
entry.registration = registration
|
||||
setStore(id, { registration, browser: null, error: undefined })
|
||||
}
|
||||
// A new session appears in the UI before its server-side creation finishes. The listener
|
||||
// belongs to this provider, not to the route effect that happened to call attach().
|
||||
const data = server.ctx.data
|
||||
const unsubscribe = runWithOwner(owner, () =>
|
||||
data.on("session.created", (event) => {
|
||||
if (event.data.sessionID === sessionID) register()
|
||||
}),
|
||||
)
|
||||
if (!data.session.creating(sessionID)) register()
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.()
|
||||
clearTimeout(entry.retry)
|
||||
entry.registration?.close()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
const registration = live.get(key(server, sessionID))?.registration
|
||||
if (!registration) return Promise.reject(new Error("browser.pane.unavailable"))
|
||||
return registration.command(command)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import type { SessionModel } from "../model"
|
||||
import { isSessionBrowserTab, sessionBrowserTab } from "../helpers"
|
||||
import { useBrowserAttachments } from "./attachments"
|
||||
|
||||
export function createSessionBrowser(session: SessionModel) {
|
||||
const attachments = useBrowserAttachments()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [local, setLocal] = createStore({ error: undefined as string | undefined })
|
||||
const attachment = () => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
return sessionID ? attachments.state(server, sessionID) : undefined
|
||||
}
|
||||
const available = createMemo(
|
||||
() =>
|
||||
attachments.enabled() &&
|
||||
attachments.supported(server) &&
|
||||
!!session.identity.sessionID() &&
|
||||
!server.health?.incompatible &&
|
||||
session.isDesktop(),
|
||||
)
|
||||
const attached = () => attachment()?.registration !== undefined
|
||||
const browserTabs = createMemo(
|
||||
() =>
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
setLocal("error", undefined)
|
||||
const owner = session.ownership.capture()
|
||||
void attachments.command(server, sessionID, command).catch(() => {
|
||||
if (owner.current()) setLocal("error", language.t("common.requestFailed"))
|
||||
})
|
||||
}
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().active(),
|
||||
(active) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === active)
|
||||
if (tab && tab.id !== attachment()?.browser?.focusedTabID) command({ type: "tabs.focus", tabID: tab.id })
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => session.layout.tabs().all(),
|
||||
(current, previous) => {
|
||||
previous
|
||||
?.filter((key) => isSessionBrowserTab(key) && !current.includes(key))
|
||||
.forEach((key) => {
|
||||
const tab = attachment()?.browser?.tabs.find((tab) => sessionBrowserTab(tab.id) === key)
|
||||
if (tab) command({ type: "tabs.close", tabID: tab.id })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
// Mirror the desktop's tab inventory into this session's layout tabs. Only tabs new since the last
|
||||
// inventory are added, so a layout tab the user just closed is not reopened before the desktop confirms.
|
||||
createEffect(
|
||||
on(
|
||||
() => attachment()?.browser?.tabs.map((tab) => sessionBrowserTab(tab.id)),
|
||||
(ids, previous) => {
|
||||
if (!ids) return
|
||||
const known = new Set(previous ?? [])
|
||||
batch(() => {
|
||||
const tabs = session.layout.tabs()
|
||||
tabs
|
||||
.all()
|
||||
.filter((key) => isSessionBrowserTab(key) && !ids.includes(key))
|
||||
.forEach(tabs.close)
|
||||
const current = tabs.all()
|
||||
const added = ids.filter((key) => !known.has(key) && !current.includes(key))
|
||||
if (added.length) tabs.setAll([...current, ...added])
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
return {
|
||||
available,
|
||||
attached,
|
||||
opened: () => attached() && browserTabs().length > 0,
|
||||
state: () => attachment()?.browser ?? null,
|
||||
tabs: browserTabs,
|
||||
active: () =>
|
||||
browserTabs().find((tab) => sessionBrowserTab(tab.id) === session.layout.tabs().active()) ??
|
||||
browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ??
|
||||
browserTabs()[0],
|
||||
error: () => local.error ?? attachment()?.error,
|
||||
registration: () => attachment()?.registration,
|
||||
close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)),
|
||||
open: () => command({ type: "tabs.open" }),
|
||||
command,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Loader } from "@opencode-ai/ui/loader"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: {
|
||||
registration: BrowserPaneRegistration
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
visible: boolean
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const state = props.browser.active
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
const covered = (rect: DOMRect) =>
|
||||
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
if (!tab) {
|
||||
props.registration.setLayout()
|
||||
return
|
||||
}
|
||||
const rect = surface.getBoundingClientRect()
|
||||
const zoom = platform.webviewZoom?.() ?? 1
|
||||
const left = Math.round(rect.left * zoom)
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
surface.closest(".bg-v2-background-bg-deep") ?? document.documentElement,
|
||||
).backgroundColor
|
||||
const next = `${tab.id}:${visible}:${left}:${top}:${right}:${bottom}:${color}:${window.devicePixelRatio}`
|
||||
if (next !== layout) {
|
||||
layout = next
|
||||
// Let the browser resolve the semantic backdrop color, including custom
|
||||
// themes using color formats that Electron's color parser cannot read.
|
||||
if (paint) {
|
||||
paint.clearRect(0, 0, 1, 1)
|
||||
paint.fillStyle = color
|
||||
paint.fillRect(0, 0, 1, 1)
|
||||
}
|
||||
const rgba = paint?.getImageData(0, 0, 1, 1).data
|
||||
props.registration.setLayout({
|
||||
tabID: tab.id,
|
||||
visible,
|
||||
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
||||
background: rgba ? [rgba[0], rgba[1], rgba[2], rgba[3]] : undefined,
|
||||
radius: Math.round(10 * zoom),
|
||||
})
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
frame = undefined
|
||||
measure()
|
||||
if (performance.now() < until) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
const schedule = (duration = 0) => {
|
||||
until = Math.max(until, performance.now() + duration)
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => platform.webviewZoom?.(),
|
||||
() => dialog.active,
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
],
|
||||
() => schedule(300),
|
||||
),
|
||||
)
|
||||
// ResizeObserver runs after layout in the same frame; measuring here instead of on the next
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => surface, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
portals.observe(document.body, { childList: true })
|
||||
onCleanup(() => portals.disconnect())
|
||||
const appearance = new MutationObserver(() => schedule(300))
|
||||
appearance.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "data-theme"] })
|
||||
onCleanup(() => appearance.disconnect())
|
||||
createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300))
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
props.registration.setLayout()
|
||||
})
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
import FileTree from "@/session/files/file-tree"
|
||||
@@ -20,6 +21,8 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
const browserTabID = "session-side-panel-browser-tab"
|
||||
const browserTabPanelID = "session-side-panel-browser-tabpanel"
|
||||
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab } from "@/session/files/session-context-tab"
|
||||
import { SortableTab } from "@/session/files/tab"
|
||||
@@ -33,6 +36,8 @@ import { useSettings } from "@/settings/model"
|
||||
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
isSessionBrowserTab,
|
||||
sessionBrowserTab,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
shouldShowFileTree,
|
||||
@@ -41,6 +46,8 @@ import {
|
||||
import { setSessionHandoff } from "@/session/handoff"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
|
||||
import { SessionBrowserPane } from "@/session/browser/pane"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
type ReviewDiff = FileDiffInfo
|
||||
type RenderDiff = FileDiffInfo
|
||||
@@ -65,6 +72,7 @@ export function SessionSidePanel(props: {
|
||||
reviewPresent?: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -165,6 +173,7 @@ export function SessionSidePanel(props: {
|
||||
review: reviewTab,
|
||||
hasReview: () => props.canReview,
|
||||
fileBrowser: () => true,
|
||||
browser: props.browser.attached,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
@@ -202,7 +211,7 @@ export function SessionSidePanel(props: {
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
const browserTab = createMemo(() => {
|
||||
const fileTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && file.pathFromTab(active)) return active
|
||||
@@ -212,11 +221,11 @@ export function SessionSidePanel(props: {
|
||||
// selects Review while the tab For replaces a preview trigger, which would
|
||||
// otherwise dispose the sidebar and reset scroll.
|
||||
const fileBrowserMounted = createMemo(() => {
|
||||
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
|
||||
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("file.close"))
|
||||
@@ -298,7 +307,7 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
<Show when={reviewTab() && props.canReview}>
|
||||
@@ -345,8 +354,7 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
<For each={panelTabs()}>
|
||||
{(tab) => (
|
||||
<Show
|
||||
when={tab === SESSION_OPEN_FILE_TAB}
|
||||
<Switch
|
||||
fallback={
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
@@ -357,73 +365,143 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Match when={isSessionBrowserTab(tab)}>
|
||||
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
|
||||
{(item) => (
|
||||
<SortableTab
|
||||
tab={tab}
|
||||
index={tabs().all().indexOf(tab)}
|
||||
onTabClose={() => props.browser.close(item().id)}
|
||||
id={`${browserTabID}-${item().id}`}
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={tab === SESSION_OPEN_FILE_TAB}>
|
||||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
class="group"
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
closeButton={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<Keybind keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100"
|
||||
classList={{ "opacity-100": activeTab() === SESSION_OPEN_FILE_TAB }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
tabs().close(SESSION_OPEN_FILE_TAB)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{/* With only files to add, the plus stays a one-click "Open file" button. */}
|
||||
<Show
|
||||
when={props.browser.available()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
value={
|
||||
<>
|
||||
{language.t("command.file.open")}
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() => openFileBrowser()}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
value={language.t("session.tab.add")}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("session.tab.add")}
|
||||
// The tablist redirects focus entering it to the selected
|
||||
// tab, which counts as focus-outside and closes the menu.
|
||||
onPointerDown={(event: PointerEvent) => event.preventDefault()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
onSelect={openFileBrowser}
|
||||
shortcut={
|
||||
<Show when={openFileKeybind().length > 0}>
|
||||
<Keybind keys={openFileKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="open-file" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={props.browser.open}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
<div
|
||||
@@ -474,6 +552,30 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={props.browser.opened()}>
|
||||
<div
|
||||
id={browserTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
props.browser.active() ? `${browserTabID}-${props.browser.active()?.id}` : undefined
|
||||
}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
|
||||
inert={!isSessionBrowserTab(activeTab()) || undefined}
|
||||
>
|
||||
<Show when={props.browser.registration()} keyed>
|
||||
{(registration) => (
|
||||
<SessionBrowserPane
|
||||
registration={registration}
|
||||
browser={props.browser}
|
||||
visible={isSessionBrowserTab(activeTab())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={fileBrowserMounted()}>
|
||||
<div
|
||||
id={fileBrowserTabPanelID}
|
||||
@@ -484,11 +586,11 @@ export function SessionSidePanel(props: {
|
||||
inert={!fileBrowserVisible() || undefined}
|
||||
>
|
||||
<SessionFileBrowserTab
|
||||
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={
|
||||
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
|
||||
}
|
||||
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
@@ -15,6 +15,10 @@ export function SortableTab(props: {
|
||||
temporary?: boolean
|
||||
onTabClose: (tab: string) => void
|
||||
onTabDoubleClick?: (tab: string) => void
|
||||
/** Replaces the file visual for non-file tabs such as the browser. */
|
||||
children?: JSX.Element
|
||||
id?: string
|
||||
ariaControls?: string
|
||||
}): JSX.Element {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
@@ -29,6 +33,7 @@ export function SortableTab(props: {
|
||||
},
|
||||
})
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const custom = children(() => props.children)
|
||||
const content = createMemo(() => {
|
||||
const value = path()
|
||||
if (!value) return
|
||||
@@ -39,6 +44,8 @@ export function SortableTab(props: {
|
||||
<div class="relative">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
id={props.id}
|
||||
aria-controls={props.ariaControls}
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||
closeButton={
|
||||
@@ -63,7 +70,9 @@ export function SortableTab(props: {
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
<Show when={content()}>{(value) => value()}</Show>
|
||||
<Show when={custom()} fallback={<Show when={content()}>{(value) => value()}</Show>}>
|
||||
{custom()}
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
SESSION_BROWSER_TAB,
|
||||
sessionBrowserTab,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenReviewFile,
|
||||
createOpenSessionFileTab,
|
||||
@@ -233,4 +235,49 @@ describe("createSessionTabs", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("exposes one browser tab without treating it as a file tab", () => {
|
||||
createRoot((dispose) => {
|
||||
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
|
||||
const result = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: () => undefined,
|
||||
normalizeTab: (tab) => tab,
|
||||
browser: () => true,
|
||||
})
|
||||
|
||||
expect(result.panelTabs()).toEqual([SESSION_BROWSER_TAB])
|
||||
expect(result.openedTabs()).toEqual([])
|
||||
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps browser tabs in layout order beside file tabs, and drops them when the browser is detached", () => {
|
||||
const first = sessionBrowserTab("first")
|
||||
const second = sessionBrowserTab("second")
|
||||
const input = {
|
||||
tabs: () => ({ active: () => second, all: () => [first, "file://src/a.ts", second] }),
|
||||
pathFromTab: (tab: string) => (tab.startsWith("file://") ? tab.slice(7) : undefined),
|
||||
normalizeTab: (tab: string) => tab,
|
||||
}
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => true })
|
||||
expect(result.panelTabs()).toEqual([first, "file://src/a.ts", second])
|
||||
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe(second)
|
||||
expect(result.activeFileTab()).toBeUndefined()
|
||||
expect(result.closableTab()).toBe(second)
|
||||
dispose()
|
||||
})
|
||||
createRoot((dispose) => {
|
||||
const result = createSessionTabs({ ...input, browser: () => false })
|
||||
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||
expect(result.activeTab()).toBe("file://src/a.ts")
|
||||
expect(result.closableTab()).toBe("file://src/a.ts")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,14 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
|
||||
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
|
||||
export {
|
||||
SESSION_BROWSER_TAB,
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
sessionBrowserTab,
|
||||
isSessionBrowserTab,
|
||||
} from "@/shell/state/session-tabs"
|
||||
|
||||
const emptyTabs: string[] = []
|
||||
|
||||
@@ -20,6 +25,7 @@ type TabsInput = {
|
||||
review?: Accessor<boolean>
|
||||
hasReview?: Accessor<boolean>
|
||||
fileBrowser?: Accessor<boolean>
|
||||
browser?: Accessor<boolean>
|
||||
}
|
||||
|
||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||
@@ -30,6 +36,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||
const browser = input.browser ?? (() => false)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openFileOpen = createMemo(
|
||||
() =>
|
||||
@@ -44,6 +51,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (tab === "context" || tab === "review") return []
|
||||
if (isSessionBrowserTab(tab)) return browser() ? [tab] : []
|
||||
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
@@ -54,13 +62,16 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
|
||||
equals: same,
|
||||
})
|
||||
const openedTabs = createMemo(
|
||||
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
|
||||
emptyTabs,
|
||||
{ equals: same },
|
||||
)
|
||||
const activeTab = createMemo(() => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (active === "review" && review()) return active
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
@@ -70,15 +81,16 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
if (review() && hasReview()) return "review"
|
||||
return "empty"
|
||||
})
|
||||
const activeFileTab = createMemo(() => {
|
||||
const activeFileTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
const closableTab = createMemo(() => {
|
||||
const closableTab = createMemo<string | undefined>(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||
if (active && isSessionBrowserTab(active) && browser()) return active
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useData } from "@/runtime/server/current"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { containsDirectory, isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useBrowserAttachments } from "./browser/attachments"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
normalizeSessionTab,
|
||||
@@ -28,6 +29,7 @@ export function useSessionModel() {
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const shellTabs = useTabs()
|
||||
const attachments = useBrowserAttachments()
|
||||
const layout = useSessionLayout()
|
||||
const location = useWorkspaceLocation()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -82,6 +84,11 @@ export function useSessionModel() {
|
||||
review: isDesktop,
|
||||
hasReview: canReview,
|
||||
fileBrowser: () => isDesktop() && !!sessionID(),
|
||||
// Same flag the side panel uses, so keyboard tab commands see the browser tab the panel shows.
|
||||
browser: () => {
|
||||
const id = sessionID()
|
||||
return !!id && attachments.state(server, id)?.registration !== undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
import type { createSessionBrowser } from "../browser/model"
|
||||
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
@@ -144,7 +145,11 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
|
||||
export function SessionDesktopReview(props: {
|
||||
review: SessionReviewModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
present?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<SessionSidePanel
|
||||
@@ -168,6 +173,7 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
|
||||
reviewPresent={props.present}
|
||||
size={props.review.screen.size}
|
||||
stacked={props.review.screen.side.layout().stacked}
|
||||
browser={props.browser}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./compos
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -47,6 +48,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
@@ -377,7 +379,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
setStore("sideReviewPresent", false)
|
||||
}}
|
||||
>
|
||||
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
|
||||
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Show } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -12,6 +13,7 @@ const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical
|
||||
export const SettingsExperimental: Component = () => {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,6 +31,22 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe("settings schema", () => {
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
@@ -130,6 +131,12 @@ describe("settings schema", () => {
|
||||
expect(decode(encode(settings))).toEqual(settings)
|
||||
})
|
||||
|
||||
test("browser attachment is opt-in and preserves an explicit choice", () => {
|
||||
expect(decode({}).general.experimentalBrowser).toBe(false)
|
||||
expect(decode({ general: { experimentalBrowser: true } }).general.experimentalBrowser).toBe(true)
|
||||
expect(decode({ general: { experimentalBrowser: false } }).general.experimentalBrowser).toBe(false)
|
||||
})
|
||||
|
||||
test.each([undefined, null, false, 7, "invalid", []].map((invalid) => [invalid]))(
|
||||
"defaults malformed sections without losing other sections: %j",
|
||||
(invalid) => {
|
||||
|
||||
@@ -95,6 +95,7 @@ const generalSchema = Persistence.struct({
|
||||
mobileDiffWrap: Schema.Boolean,
|
||||
terminalPlacement: Schema.Literals(["side", "bottom"]),
|
||||
followUpBehavior: Schema.Literals(["queue", "steer"]),
|
||||
experimentalBrowser: Schema.Boolean,
|
||||
})
|
||||
|
||||
const appearanceSchema = Persistence.struct({
|
||||
@@ -251,6 +252,7 @@ export const defaultSettings: Settings = {
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
experimentalBrowser: false,
|
||||
},
|
||||
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
|
||||
keybinds: {},
|
||||
@@ -358,6 +360,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setFollowUpBehavior(value: FollowUpBehavior) {
|
||||
setStore("general", "followUpBehavior", value)
|
||||
},
|
||||
experimentalBrowser: withFallback(
|
||||
() => store.general?.experimentalBrowser,
|
||||
defaultSettings.general.experimentalBrowser,
|
||||
),
|
||||
setExperimentalBrowser(value: boolean) {
|
||||
setStore("general", "experimentalBrowser", value)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
fileTree: showFileTree,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { BrowserAttachmentsProvider } from "@/session/browser/attachments"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
@@ -73,7 +74,9 @@ function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||
export const SESSION_BROWSER_TAB = "browser"
|
||||
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
|
||||
export const isSessionBrowserTab = (tab: string | undefined) =>
|
||||
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
|
||||
|
||||
export type SessionTabs = {
|
||||
active?: string
|
||||
|
||||
@@ -111,7 +111,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /filesystem\/watcher-binding\.ts$/ }, () => ({
|
||||
build.onLoad({ filter: /filesystem[/\\]watcher-binding\.ts$/ }, () => ({
|
||||
contents: `export default () => require(${JSON.stringify(parcelWatcherPackage)})`,
|
||||
loader: "js",
|
||||
}))
|
||||
|
||||
@@ -44,6 +44,10 @@ export default Runtime.handler(
|
||||
)
|
||||
const output = yield* Effect.promise(() => response.text())
|
||||
if (output) process.stdout.write(output + (output.endsWith(EOL) ? "" : EOL))
|
||||
if (!response.ok) {
|
||||
process.stderr.write(`HTTP ${response.status} ${response.statusText}${EOL}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -970,6 +970,20 @@ export type SessionLogOutput =
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerContext?:
|
||||
| {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: Provider.ID
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: Schema.Json
|
||||
}
|
||||
| undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,6 +138,15 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionProviderContextProvenance = {
|
||||
providerID: string
|
||||
provider: string
|
||||
modelID: string
|
||||
route: string
|
||||
protocol: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -183,6 +192,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
|
||||
|
||||
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
|
||||
|
||||
export type ProviderCompaction = { mode: "local" | "provider" }
|
||||
|
||||
export type ModelCapabilities = {
|
||||
tools: boolean
|
||||
input: Array<string>
|
||||
@@ -201,18 +212,6 @@ export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type FormWhen = {
|
||||
key: string
|
||||
op: "eq" | "neq"
|
||||
@@ -512,19 +511,6 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -539,6 +525,8 @@ export type SessionMessageCompactionFailed = {
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
|
||||
export type SessionInboxSynthetic = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -1345,23 +1333,6 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1381,6 +1352,19 @@ export type ModelCompatibility = {
|
||||
requireAssistantAfterTool?: boolean
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compaction?: ProviderCompaction
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
@@ -1742,10 +1726,37 @@ export type SessionMessageToolStateError = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
providerContext?: SessionProviderContext
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
@@ -1824,6 +1835,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -1999,6 +2011,7 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
@@ -2008,6 +2021,7 @@ export type ConfigEntry =
|
||||
body?: { [x: string]: JsonValue }
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
@@ -2085,6 +2099,11 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type SessionMessageAssistantTool1 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
@@ -3080,6 +3099,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3359,6 +3390,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3638,6 +3681,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -130,22 +130,17 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible": {
|
||||
case "@ai-sdk/openai-compatible":
|
||||
if (typeof input.settings.baseURL !== "string") return
|
||||
const provider = input.providerID === "fireworks-ai" ? "fireworks" : input.providerID
|
||||
const native = ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks", "groq", "togetherai"].includes(
|
||||
provider,
|
||||
)
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/${native ? provider : "openai-compatible"}`,
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(native ? {} : { provider: input.providerID }),
|
||||
provider: input.providerID,
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
|
||||
},
|
||||
}
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
|
||||
@@ -76,6 +76,7 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider.package,
|
||||
compaction: model.compaction ?? provider.compaction,
|
||||
settings: Provider.mergeOverlay(provider.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider.body, model.body),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "../../permission.js"
|
||||
import type { LocationMutation } from "../../location-mutation.js"
|
||||
import type { FileAccess } from "../../file-access.js"
|
||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -27,10 +27,7 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ 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.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)
|
||||
@@ -76,6 +77,7 @@ 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.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)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
export * as FileAccess from "./file-access.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Location } from "./location.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import type { SessionErrors } from "./session/error.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
/** Selects the external approval boundary; it does not validate the target type. */
|
||||
kind: Kind.pipe(Schema.optional),
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: AbsolutePath
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
}
|
||||
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: AbsolutePath
|
||||
/** Location-relative for internal paths, absolute for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
export type Invocation = Pick<Tool.Context, "sessionID" | "agent" | "messageID" | "id">
|
||||
|
||||
export interface ReadOptions {
|
||||
/** A target already authorized by this invocation, used for filename recovery. */
|
||||
readonly siblingOf: Target
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Resolve a lexical path and its permission resources, without requesting approval. */
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
/** Approve external directories in one batch, preserving first-seen resource order. */
|
||||
readonly authorizeExternal: (
|
||||
targets: readonly Target[],
|
||||
context: Invocation,
|
||||
metadata?: Permission.AssertInput["metadata"],
|
||||
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
/** Resolve a read target and obtain external-directory approval before read approval. */
|
||||
readonly authorizeRead: (
|
||||
file: string,
|
||||
context: Invocation,
|
||||
options?: ReadOptions,
|
||||
) => Effect.Effect<Target, FSUtil.Error | Error | SessionErrors.NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
|
||||
|
||||
/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
|
||||
const normalized = FSUtil.windowsPath(input)
|
||||
return path.resolve(
|
||||
directory,
|
||||
normalized === "~"
|
||||
? home
|
||||
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
|
||||
? path.join(home, normalized.slice(2))
|
||||
: normalized,
|
||||
)
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
const invocation = (context: Invocation) => ({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool" as const, messageID: context.messageID, id: context.id },
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
|
||||
const worktree = path.resolve(location.project.directory)
|
||||
const internal =
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
|
||||
if (internal) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(absolute),
|
||||
externalDirectory: {
|
||||
action: "external_directory",
|
||||
directory,
|
||||
resource: slash(path.join(directory, "*")),
|
||||
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
|
||||
},
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* (
|
||||
targets: readonly Target[],
|
||||
context: Invocation,
|
||||
metadata?: Permission.AssertInput["metadata"],
|
||||
) {
|
||||
const external = Array.dedupeWith(
|
||||
targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])),
|
||||
(left, right) => left.resource === right.resource,
|
||||
)
|
||||
if (external.length === 0) return
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
...invocation(context),
|
||||
})
|
||||
})
|
||||
|
||||
const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* (
|
||||
file: string,
|
||||
context: Invocation,
|
||||
options?: ReadOptions,
|
||||
) {
|
||||
const target = yield* resolve({ path: file, kind: options ? "file" : undefined })
|
||||
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
|
||||
|
||||
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
|
||||
if (!sibling) yield* authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: "read",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
...invocation(context),
|
||||
})
|
||||
return target
|
||||
})
|
||||
|
||||
return Service.of({ resolve, authorizeExternal, authorizeRead })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
|
||||
@@ -7,11 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import type { Files } from "./environment/index.js"
|
||||
import type { FileAccess } from "./file-access.js"
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: string
|
||||
readonly resource: string
|
||||
}
|
||||
export type Target = Pick<FileAccess.Target, "absolute" | "resource">
|
||||
|
||||
export interface WriteInput {
|
||||
readonly target: Target
|
||||
|
||||
@@ -42,6 +42,7 @@ export const layer = Layer.effect(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
],
|
||||
(error) => {
|
||||
const mapped: Error = input.model
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
@@ -82,7 +82,7 @@ const nodes = [
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
Mcp.node,
|
||||
|
||||
@@ -1,130 +1,3 @@
|
||||
export * as LocationMutation from "./location-mutation.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. A leading `~` expands to
|
||||
* the home directory; other relative paths resolve from the active Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
/** Selects the external approval boundary; it does not validate the target type. */
|
||||
kind: Kind.pipe(Schema.optional),
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
}
|
||||
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Absolute lexical path. */
|
||||
readonly absolute: string
|
||||
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. A leading `~` expands
|
||||
* to the home directory; other relative paths resolve from the Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
|
||||
const normalized = FSUtil.windowsPath(input)
|
||||
return path.resolve(
|
||||
directory,
|
||||
normalized === "~"
|
||||
? home
|
||||
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
|
||||
? path.join(home, normalized.slice(2))
|
||||
: normalized,
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = resolvePath(location.directory, input.path)
|
||||
const worktree = path.resolve(location.project.directory)
|
||||
const internal =
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
|
||||
if (internal) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(absolute),
|
||||
externalDirectory: {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join(
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
},
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
return Service.of({ resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
/** @deprecated Use FileAccess for path resolution and authorization. */
|
||||
export { FileAccess as LocationMutation } from "./file-access.js"
|
||||
export * from "./file-access.js"
|
||||
|
||||
@@ -52,10 +52,24 @@ export class UnresolvedProviderVariablesError extends Schema.TaggedError<Unresol
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCompactionError>()(
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
route: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Provider compaction is not supported by ${this.providerID}/${this.modelID} (${this.route})`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| UnresolvedProviderVariablesError
|
||||
| UnsupportedCompactionError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
@@ -69,6 +83,8 @@ export interface Resolved {
|
||||
readonly cost: Info["cost"]
|
||||
/** 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"]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -115,9 +131,20 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
): Effect.Effect<
|
||||
LanguageModel,
|
||||
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
|
||||
> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
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)
|
||||
return Effect.succeed(resolved)
|
||||
return Effect.fail(
|
||||
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
@@ -296,6 +323,7 @@ export const layer = Layer.effect(
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: selected.compaction,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileAccess } from "../file-access.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Form } from "../form.js"
|
||||
@@ -44,7 +45,6 @@ import { Integration } from "../integration.js"
|
||||
import { Job } from "../job.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -79,6 +79,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode-ai/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -102,6 +103,7 @@ const services = [
|
||||
Credential.Service,
|
||||
Bus.Service,
|
||||
Environment.Service,
|
||||
FileAccess.Service,
|
||||
FileMutation.Service,
|
||||
Formatter.Service,
|
||||
LocationWatcherPolicy.Service,
|
||||
@@ -115,7 +117,6 @@ const services = [
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
Location.Service,
|
||||
LocationMutation.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
Npm.Service,
|
||||
@@ -151,6 +152,7 @@ export const requirements = LayerNode.group([
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
LocationWatcherPolicy.node,
|
||||
@@ -164,7 +166,6 @@ export const requirements = LayerNode.group([
|
||||
Job.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
@@ -192,6 +193,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
|
||||
@@ -263,6 +263,7 @@ export const OpenAIPlugin = define({
|
||||
const account = chatgpt.metadata?.accountID
|
||||
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
|
||||
originator: "opencode",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
|
||||
})
|
||||
for (const model of item.models.values()) {
|
||||
|
||||
@@ -25,6 +25,7 @@ const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.String,
|
||||
expires_in: Schema.Number,
|
||||
org_id: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const TokenPending = Schema.Struct({ error: Schema.String })
|
||||
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||
@@ -68,11 +69,23 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||
Token,
|
||||
)
|
||||
// Persist rotated tokens without depending on discovery requests.
|
||||
return {
|
||||
...credential,
|
||||
access: token.access_token,
|
||||
refresh: token.refresh_token,
|
||||
expires: Date.now() + token.expires_in * 1000,
|
||||
metadata:
|
||||
token.org_id == null
|
||||
? credential.metadata
|
||||
: {
|
||||
...credential.metadata,
|
||||
orgID: token.org_id,
|
||||
orgName:
|
||||
credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string"
|
||||
? credential.metadata.orgName
|
||||
: token.org_id,
|
||||
},
|
||||
}
|
||||
}),
|
||||
label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined),
|
||||
@@ -163,11 +176,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined)
|
||||
existing.body = Provider.mergeOverlay(
|
||||
existing.body,
|
||||
variantBody(variant.body, model.package ?? item.package ?? source?.provider.package),
|
||||
)
|
||||
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
if (config.cost !== undefined)
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
@@ -236,18 +245,6 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
}
|
||||
|
||||
function variantBody(body: Readonly<Record<string, unknown>>, packageName: string | undefined) {
|
||||
if (packageName !== Provider.aisdk("@ai-sdk/openai")) return body
|
||||
const { reasoningEffort, reasoningSummary, ...native } = body
|
||||
const reasoning = {
|
||||
...(typeof reasoningEffort === "string" ? { effort: reasoningEffort } : {}),
|
||||
...(typeof reasoningSummary === "string" ? { summary: reasoningSummary } : {}),
|
||||
}
|
||||
if (Object.keys(reasoning).length === 0) return body
|
||||
// Existing Console variants stored SDK options here before V2 consumed raw bodies.
|
||||
return Provider.mergeOverlay({ reasoning }, native)
|
||||
}
|
||||
|
||||
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
|
||||
return (
|
||||
body &&
|
||||
@@ -302,7 +299,13 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
const org =
|
||||
token.org_id == null
|
||||
? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
: orgs.find((org) => org.id === token.org_id)
|
||||
if (token.org_id != null && !org) {
|
||||
return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`))
|
||||
}
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth" as const,
|
||||
methodID,
|
||||
|
||||
@@ -116,15 +116,15 @@ opencode2 mcp list
|
||||
```
|
||||
|
||||
Remote servers use OAuth by default. If `mcp list` reports that a server needs
|
||||
authentication, run the OAuth flow and then verify the connection:
|
||||
authentication, tell the user to run `/mcps`, select the server, and sign in.
|
||||
Do not run `opencode2 mcp auth` through the shell tool: it starts an interactive
|
||||
flow whose authorization link can be hidden in background process output.
|
||||
Use the user-facing MCP interface instead.
|
||||
|
||||
```sh
|
||||
opencode2 mcp auth <name>
|
||||
opencode2 mcp list
|
||||
```
|
||||
Report the server as configured but awaiting sign-in until its connection
|
||||
status confirms it is connected.
|
||||
|
||||
The auth command prints an authorization URL, waits for the browser redirect,
|
||||
and stores credentials outside the OpenCode configuration. Do not ask for or
|
||||
OAuth credentials are stored outside the OpenCode configuration. Do not ask for or
|
||||
store an API key when the server supports OAuth. Use header-based credentials
|
||||
only when OAuth is unavailable or the user explicitly requires them, and use an
|
||||
environment substitution such as `{env:MCP_API_KEY}` instead of writing a
|
||||
|
||||
@@ -15,12 +15,15 @@ import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionRunnerRetry } from "./runner/retry.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
@@ -199,6 +202,32 @@ const estimatePart = (part: ContentPart): number => {
|
||||
)
|
||||
}
|
||||
|
||||
/** Keep whole, real user messages, never synthetic guidance or half an attachment/tool exchange. */
|
||||
export const retainUsers = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: Pick<SessionRunnerModel.Resolved, "ref" | "capabilities">,
|
||||
keepTokens: number,
|
||||
) => {
|
||||
const users = SessionModelRequest.boundImages(
|
||||
SessionModelRequest.unsupportedParts(
|
||||
toLLMMessages(
|
||||
messages.filter((message) => message.type === "user").map((message) => ({ ...message, skills: undefined })),
|
||||
model.ref,
|
||||
),
|
||||
model.capabilities,
|
||||
),
|
||||
)
|
||||
let tokens = 0
|
||||
let start = users.length
|
||||
for (let index = users.length - 1; index >= 0; index--) {
|
||||
const size = users[index].content.reduce((sum, part) => sum + estimatePart(part), 0)
|
||||
if (tokens + size > keepTokens) break
|
||||
tokens += size
|
||||
start = index
|
||||
}
|
||||
return users.slice(start)
|
||||
}
|
||||
|
||||
export const truncateToolOutput = (value: string) => {
|
||||
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
|
||||
let end = 0
|
||||
@@ -336,6 +365,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings, Editor>({
|
||||
name: "session-compaction",
|
||||
@@ -357,6 +387,132 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const started = (input: ExecuteInput, recent: string) =>
|
||||
input.started
|
||||
? Effect.void
|
||||
: bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.context.session.id,
|
||||
reason: input.reason,
|
||||
recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
|
||||
const interrupted = (input: ExecuteInput) =>
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: input.context.session.id,
|
||||
reason: input.reason,
|
||||
inputID: input.inputID,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void
|
||||
const compactionRequest = (
|
||||
input: ExecuteInput,
|
||||
messages: readonly SessionMessage.Info[],
|
||||
prompt: Message[],
|
||||
webSocket?: "session",
|
||||
) => {
|
||||
const context = input.context
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: context.agent.info,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
initial: context.initial,
|
||||
messages,
|
||||
})
|
||||
return input.prepare({
|
||||
kind: "compaction",
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
...prompt,
|
||||
],
|
||||
},
|
||||
webSocket,
|
||||
})
|
||||
}
|
||||
/** The durable transcript since the last local summary, re-expanding every native window. */
|
||||
const original = (sessionID: SessionSchema.ID) => SessionHistory.load(db, sessionID, "local").pipe(Effect.orDie)
|
||||
const executeProvider = Effect.fn("SessionCompaction.executeProvider")(function* (input: ExecuteInput) {
|
||||
const context = input.context
|
||||
const reject = (message: string) =>
|
||||
failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
inputID: input.inputID,
|
||||
error: { type: "provider.unsupported-operation", message },
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, context.messages, [], "session")
|
||||
const request = prepared.request
|
||||
const provenance = SessionProviderContext.provenance(context.model)
|
||||
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
|
||||
// History is selected before request hooks. Until that interface can select on the final route,
|
||||
// require routing in the catalog; never install a checkpoint that the next request would skip.
|
||||
if (
|
||||
!SessionProviderContext.compatible(
|
||||
provenance,
|
||||
SessionProviderContext.provenance({ model: request.model, ref: context.model.ref }),
|
||||
)
|
||||
)
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
yield* started(input, "")
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
// One physical attempt, with no local-summary fallback or provider-error retry.
|
||||
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" })
|
||||
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 })
|
||||
// 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`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (result.usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: context.session.id,
|
||||
source: "compaction" as const,
|
||||
...SessionUsage.record(result.usage, context.model.cost),
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
model: context.model.ref,
|
||||
text: "",
|
||||
recent: "",
|
||||
providerContext: SessionProviderContext.encode(provenance, result.replacement),
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
}),
|
||||
).pipe(
|
||||
Effect.onInterrupt(() => interrupted(input)),
|
||||
Effect.catchTag("AI.Error", (cause) =>
|
||||
failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
inputID: input.inputID,
|
||||
error: toSessionError(cause),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
|
||||
const context = input.context
|
||||
const history = splitHistory(context.messages, state.get().tokens)
|
||||
@@ -367,13 +523,7 @@ export const layer = Layer.effect(
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
if (!input.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
recent: history.recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
yield* started(input, history.recent)
|
||||
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
@@ -388,35 +538,13 @@ export const layer = Layer.effect(
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: context.agent.info,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
initial: context.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* input.prepare({
|
||||
kind: "compaction",
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
const prepared = yield* compactionRequest(input, history.messages, [
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
),
|
||||
])
|
||||
const retry = yield* SessionRunnerRetry.policy(context.session.id)
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
for (const request of [
|
||||
@@ -501,20 +629,7 @@ export const layer = Layer.effect(
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.andThen(interrupted(input)))),
|
||||
)
|
||||
if (failure || hasSummarySection(chunks.join(""))) break
|
||||
}
|
||||
@@ -578,15 +693,17 @@ export const layer = Layer.effect(
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
onSuccess: (context) =>
|
||||
execute({
|
||||
onSuccess: (context) => {
|
||||
const request = {
|
||||
context,
|
||||
instructionUpdate: context.instructionUpdate,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
reason: "manual" as const,
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
}),
|
||||
}
|
||||
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -604,5 +721,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient],
|
||||
deps: [Bus.node, Database.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
@@ -156,7 +157,12 @@ const layer = Layer.effect(
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
const model = yield* resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.entriesForRunner(
|
||||
db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
return {
|
||||
session: selection.session,
|
||||
agent: selection.agent,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Instructions } from "../instructions/index.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -29,7 +30,12 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
const context = yield* SessionContext.Service
|
||||
const selection = yield* context.select(input.session.id)
|
||||
const model = yield* context.resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
database.db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { MessageDecodeError } from "./error.js"
|
||||
@@ -6,13 +6,30 @@ import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
/**
|
||||
* Which completed compactions bound a history read. Local summaries always do. Native
|
||||
* windows do for model-neutral readers (`latest`), never for the original transcript
|
||||
* (`local`), and only when the target model can replay them (a provenance).
|
||||
*/
|
||||
export type Boundary = "latest" | "local" | SessionProviderContext.Provenance
|
||||
|
||||
const replayable = (message: SessionMessage.Info, boundary: Boundary) =>
|
||||
!SessionProviderContext.isCheckpoint(message) ||
|
||||
boundary === "latest" ||
|
||||
(boundary !== "local" && SessionProviderContext.compatible(message.providerContext.provenance, boundary))
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
boundary: Boundary,
|
||||
) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -21,6 +38,19 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
boundary === "latest"
|
||||
? undefined
|
||||
: or(
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
|
||||
boundary === "local"
|
||||
? undefined
|
||||
: and(
|
||||
...Object.entries(boundary).map(
|
||||
([key, value]) =>
|
||||
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -31,6 +61,11 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.tap((message) =>
|
||||
SessionProviderContext.isCheckpoint(message)
|
||||
? SessionProviderContext.validate(message.providerContext)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
@@ -40,8 +75,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
|
||||
),
|
||||
)
|
||||
|
||||
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const messageEntries = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
boundary: Boundary,
|
||||
) {
|
||||
const compaction = yield* latestCompaction(db, sessionID, boundary)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
@@ -54,24 +93,38 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
const entries = yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
// Re-expansion may cross a native checkpoint whose completion already advanced the instruction
|
||||
// epoch: the baseline supersedes the chronological updates before it. Forks seed their baseline
|
||||
// at sequence 0 but retain parent sequences, so the copied checkpoint still retires them.
|
||||
const native = entries.findLast((entry) => SessionProviderContext.isCheckpoint(entry.message))
|
||||
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
!(entry.message.type === "system" && native && entry.seq < native.seq) && replayable(entry.message, boundary),
|
||||
)
|
||||
})
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
boundary: Boundary,
|
||||
) {
|
||||
return (yield* messageEntries(db, sessionID, boundary)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
boundary: Boundary,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, boundary)
|
||||
return {
|
||||
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||
entries: messages,
|
||||
@@ -85,12 +138,13 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
boundary: Boundary,
|
||||
) {
|
||||
const observed = yield* Instructions.read(instructions)
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, boundary)
|
||||
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
|
||||
const unsettled = messages.findIndex(
|
||||
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
|
||||
|
||||
@@ -413,6 +413,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
return
|
||||
@@ -427,6 +428,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
@@ -345,6 +346,21 @@ export const layer = Layer.effect(
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
}),
|
||||
)
|
||||
// History selects native windows against the catalog route before hooks run. A newly installed
|
||||
// routing hook must not send an existing opaque window to another deployment; `prepare` has no
|
||||
// error channel, so like hook failures this surfaces as a defect.
|
||||
const selected = SessionProviderContext.provenance(resolved)
|
||||
if (
|
||||
selected &&
|
||||
!SessionProviderContext.compatible(
|
||||
selected,
|
||||
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
|
||||
) &&
|
||||
request.messages.some((message) => message.content.some((part) => part.type === "compaction"))
|
||||
)
|
||||
return yield* Effect.die(
|
||||
new Error("Provider context is incompatible with the route selected by model request hooks"),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export * as SessionProviderContext from "./provider-context.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
|
||||
import { Schema } from "effect"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
export type Provenance = SessionProviderContext.Provenance
|
||||
export const Info = SessionProviderContext.Info
|
||||
export type Info = SessionProviderContext.Info
|
||||
|
||||
const messages = Schema.toCodecJson(Schema.Array(Message))
|
||||
|
||||
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
|
||||
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
|
||||
const model = resolved.model
|
||||
const endpoint = model.route.endpoint
|
||||
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
|
||||
return {
|
||||
providerID: resolved.ref.providerID,
|
||||
provider: model.provider,
|
||||
modelID: model.id,
|
||||
route: model.route.id,
|
||||
protocol: model.route.protocol,
|
||||
endpoint: Hash.sha256(
|
||||
JSON.stringify([
|
||||
endpoint.baseURL,
|
||||
endpoint.path,
|
||||
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const compatible = (source: Provenance, target: Provenance | undefined) =>
|
||||
target !== undefined && isDeepStrictEqual(source, target)
|
||||
|
||||
/** A completed compaction that installed a native replacement window instead of a local summary. */
|
||||
export const isCheckpoint = (
|
||||
message: SessionMessage.Info,
|
||||
): message is SessionMessage.CompactionCompleted & { readonly providerContext: Info } =>
|
||||
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined
|
||||
|
||||
/** Stores the canonical replacement, not a local summary or transport continuation.
|
||||
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
|
||||
* omission semantics, while preserving canonical binary media as equivalent base64.
|
||||
*/
|
||||
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
|
||||
version: 1,
|
||||
provenance,
|
||||
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
|
||||
JSON.stringify(
|
||||
replacement.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((part) =>
|
||||
part.type === "media" && part.data instanceof Uint8Array
|
||||
? { ...part, data: Buffer.from(part.data).toString("base64") }
|
||||
: part,
|
||||
),
|
||||
})),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
|
||||
@@ -11,6 +11,7 @@ import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -113,7 +114,12 @@ const layer = Layer.effect(
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
session.id,
|
||||
selected.instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
|
||||
@@ -35,6 +35,8 @@ export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
|
||||
export type Resolved = ModelResolver.Resolved
|
||||
@@ -57,6 +59,7 @@ export const resolved = (
|
||||
readonly variant?: Model.VariantID
|
||||
readonly cost: Model.Info["cost"]
|
||||
readonly limit: Model.Info["limit"]
|
||||
readonly compaction?: Provider.Compaction
|
||||
},
|
||||
): Resolved => ({
|
||||
model,
|
||||
@@ -68,6 +71,7 @@ export const resolved = (
|
||||
capabilities: options.capabilities,
|
||||
cost: options.cost,
|
||||
limit: options.limit,
|
||||
compaction: options.compaction,
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
@@ -274,6 +275,9 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
return assistant(message, model, providerMetadataKey)
|
||||
case "compaction":
|
||||
if (message.status !== "completed") return []
|
||||
// History selection only keeps native windows the target model can replay.
|
||||
if (SessionProviderContext.isCheckpoint(message))
|
||||
return [...SessionProviderContext.decode(message.providerContext)]
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
|
||||
@@ -172,7 +172,7 @@ const layer = Layer.effect(
|
||||
SessionHistory.decodeMessageRow,
|
||||
)
|
||||
}),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
|
||||
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID, "latest")),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
||||
@@ -46,6 +46,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
|
||||
}
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
|
||||
return { type: "provider.unsupported-operation", message: cause.message }
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
if (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -143,16 +143,8 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
const target = yield* access.resolve({ path: input.path, kind: "file" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
|
||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
@@ -218,7 +210,7 @@ export const Plugin = {
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
|
||||
fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -48,7 +48,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -63,15 +63,8 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
@@ -67,7 +67,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -82,14 +82,8 @@ export const Plugin = {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: input.path ?? "." })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission.js"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -45,29 +45,29 @@ export const toModelContent = (output: Output) =>
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: FileAccess.Target
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -86,9 +86,9 @@ export const Plugin = {
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
LocationMutation.resolvePath(location.directory, hunk.path),
|
||||
FileAccess.resolvePath(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath
|
||||
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
|
||||
? [FileAccess.resolvePath(location.directory, hunk.movePath)]
|
||||
: []),
|
||||
])
|
||||
: []
|
||||
@@ -114,17 +114,11 @@ export const Plugin = {
|
||||
const prepared: Prepared[] = []
|
||||
const updates = new Map<string, string>()
|
||||
const resolveTarget = Effect.fnUntraced(function* (value: string) {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
const target = yield* access.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
yield* access.authorizeExternal([target], context, {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
})
|
||||
return target
|
||||
})
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { SessionInstructions } from "../../session/instructions.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { ReadToolFileSystem } from "../read-filesystem.js"
|
||||
@@ -31,8 +30,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -48,37 +46,13 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
|
||||
Effect.gen(function* () {
|
||||
if (target.externalDirectory && authorizeExternal)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
})
|
||||
const read = (target: LocationMutation.Target) =>
|
||||
reader.read(AbsolutePath.make(target.absolute), target.resource, {
|
||||
const read = (target: FileAccess.Target) =>
|
||||
reader.read(target.absolute, target.resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
|
||||
const requested = yield* mutation.resolve({ path: input.path })
|
||||
yield* authorize(requested)
|
||||
const requested = yield* access.authorizeRead(input.path, context)
|
||||
const result = yield* read(requested).pipe(
|
||||
Effect.map((content) => ({ content, target: requested, path: input.path })),
|
||||
Effect.catchIf(
|
||||
@@ -89,9 +63,7 @@ export const Plugin = {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!alternate) return yield* missing(input.path, requested.absolute)
|
||||
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
|
||||
// The candidate is a sibling under the external directory already approved above.
|
||||
yield* authorize(target, false)
|
||||
const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested })
|
||||
const content = yield* read(target).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
import { Session } from "../../session.js"
|
||||
@@ -104,7 +104,7 @@ export const Plugin = {
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const compatibleShell = shellSelect.resolve({ priority: "compat" })
|
||||
@@ -117,30 +117,18 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
invocation.cwd = target.absolute
|
||||
const timeout = invocation.timeout
|
||||
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({
|
||||
path: LocationMutation.resolvePath(target.absolute, directory),
|
||||
access.resolve({
|
||||
path: FileAccess.resolvePath(target.absolute, directory),
|
||||
kind: "directory",
|
||||
}),
|
||||
)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* access.authorizeExternal([target, ...directories], context)
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const toModelContent = (output: Output) =>
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -68,15 +68,8 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: input.path, kind: "file" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,45 @@ 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", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode-ai/ai/providers/openai/responses",
|
||||
compaction: { mode: "provider" },
|
||||
models: {
|
||||
native: {},
|
||||
local: { compaction: { mode: "local" }, package: "@opencode-ai/ai/providers/openai/chat" },
|
||||
unsupported: { package: "@opencode-ai/ai/providers/openai/chat" },
|
||||
},
|
||||
},
|
||||
default: { package: "@opencode-ai/ai/providers/openai/chat", models: { chat: {} } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const native = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("native")))
|
||||
const local = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("local")))
|
||||
const unsupported = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("unsupported")))
|
||||
const defaultModel = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("chat")))
|
||||
expect(native.compaction).toEqual({ mode: "provider" })
|
||||
expect(local.compaction).toEqual({ mode: "local" })
|
||||
expect(defaultModel.compaction).toBeUndefined()
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
expect(yield* ModelResolver.fromCatalogModel(unsupported).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnsupportedCompactionError",
|
||||
message: "Provider compaction is not supported by custom/unsupported (openai-chat)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
+65
-72
@@ -4,17 +4,20 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdirScoped, withTempDir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, {
|
||||
LayerNode.compile(FileAccess.node, {
|
||||
replacements: [
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -31,21 +34,14 @@ function provide(directory: string, projectDirectory = directory) {
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationMutation", () => {
|
||||
describe("FileAccess.resolve", () => {
|
||||
it.live("resolves an active relative existing file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -57,11 +53,11 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("resolves an active relative prospective file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -71,10 +67,10 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "../outside.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -89,11 +85,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows a relative path outside the Location but inside the project worktree", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const active = path.join(directory, "packages", "opencode")
|
||||
yield* Effect.promise(() => fs.mkdir(active, { recursive: true }))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../../README.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "README.md"),
|
||||
resource: "../../README.md",
|
||||
@@ -104,37 +101,34 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("does not treat a filesystem-root project sentinel as an internal boundary", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../outside.txt" })
|
||||
expect(target.externalDirectory).toBeDefined()
|
||||
}).pipe(provide(directory, path.parse(directory).root)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a prospective target below an external symlink lexically", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
|
||||
const outside = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape")))
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -142,8 +136,8 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
const mutation = yield* LocationMutation.Service
|
||||
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
const access = yield* FileAccess.Service
|
||||
expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -152,11 +146,11 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("accepts an explicit absolute in-location target without external approval", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -167,12 +161,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for an explicit external absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -188,26 +182,26 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("resolves an existing external file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: outside, kind: "file" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -218,12 +212,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -234,19 +228,18 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
test("ignores unknown path input fields", () => {
|
||||
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
})
|
||||
})
|
||||
|
||||
test("expands a leading tilde against the home directory", () => {
|
||||
const home = path.resolve("/Users/aiden")
|
||||
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
|
||||
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
|
||||
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
|
||||
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
|
||||
expect(FileAccess.resolvePath("/project", "~", home)).toBe(home)
|
||||
expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
|
||||
expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
|
||||
expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe(
|
||||
process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"),
|
||||
)
|
||||
})
|
||||
@@ -257,16 +250,16 @@ describe("LocationMutation", () => {
|
||||
["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
|
||||
["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
|
||||
])("normalizes Windows shell drive path %s before resolution", (input, windows) => {
|
||||
expect(LocationMutation.resolvePath("/project", input)).toBe(
|
||||
expect(FileAccess.resolvePath("/project", input)).toBe(
|
||||
process.platform === "win32" ? path.resolve(windows) : path.resolve(input),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("resolves a tilde path as an external home target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "~/notes.md" })
|
||||
const absolute = path.resolve(Global.Path.home, "notes.md")
|
||||
expect(target).toMatchObject({
|
||||
absolute,
|
||||
@@ -282,8 +275,8 @@ describe("LocationMutation", () => {
|
||||
|
||||
it.live("treats a tilde path as in-location when the location is home", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "~/notes.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.resolve(Global.Path.home, "notes.md"),
|
||||
resource: "notes.md",
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity } from "./lib/tool"
|
||||
|
||||
const invocation = {
|
||||
...toolIdentity,
|
||||
sessionID: Session.ID.make("ses_file_access"),
|
||||
id: Tool.CallID.make("call-read"),
|
||||
}
|
||||
const slash = (file: string) => file.replaceAll("\\", "/")
|
||||
|
||||
function provide(requests: Permission.AssertInput[], denied?: string) {
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Permission.node.replace(
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(input)
|
||||
if (input.action === denied)
|
||||
yield* new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
describe("FileAccess.authorizeRead", () => {
|
||||
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const location = yield* Location.Service
|
||||
const target = yield* access.authorizeRead("src/../README.md", invocation)
|
||||
const absolute: AbsolutePath = target.absolute
|
||||
|
||||
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
action: "read",
|
||||
resources: ["README.md"],
|
||||
save: ["*"],
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
},
|
||||
])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("authorizes an external directory before the file's read rules", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.authorizeRead("../notes.txt", invocation)
|
||||
|
||||
expect(requests).toMatchObject([
|
||||
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
|
||||
{ action: "read", resources: [slash(target.absolute)] },
|
||||
])
|
||||
for (const request of requests) {
|
||||
expect(request).toMatchObject({
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
})
|
||||
}
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
for (const action of ["external_directory", "read"]) {
|
||||
it.live(`propagates ${action} denial without continuing authorization`, () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Permission.BlockedError)
|
||||
expect(requests.map((request) => request.action)).toEqual(
|
||||
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
|
||||
)
|
||||
}).pipe(provide(requests, action))
|
||||
})
|
||||
}
|
||||
|
||||
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const requested = yield* access.authorizeRead("../report final.txt", invocation)
|
||||
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
|
||||
yield* access.authorizeRead("../notes.txt", invocation)
|
||||
|
||||
expect(requests.map((request) => request.action)).toEqual([
|
||||
"external_directory",
|
||||
"read",
|
||||
"read",
|
||||
"external_directory",
|
||||
"read",
|
||||
])
|
||||
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("checks the external directory for a target that is not a sibling", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const requested = yield* access.authorizeRead("README.md", invocation)
|
||||
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
|
||||
|
||||
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const external = yield* tmpdirScoped()
|
||||
const git = path.join(external.path, "git")
|
||||
const hg = path.join(external.path, "hg")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(git, ".git"), { recursive: true })
|
||||
await fs.mkdir(path.join(git, "nested"))
|
||||
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
|
||||
await fs.mkdir(path.join(hg, "nested"))
|
||||
})
|
||||
const access = yield* FileAccess.Service
|
||||
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
|
||||
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
|
||||
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
|
||||
const internal = yield* access.resolve({ path: "README.md" })
|
||||
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
|
||||
|
||||
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
action: "external_directory",
|
||||
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
|
||||
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
|
||||
metadata,
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
},
|
||||
])
|
||||
|
||||
yield* access.authorizeExternal([internal], invocation)
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* access.authorizeExternal([second], invocation)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
|
||||
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { withTempDir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
|
||||
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -20,27 +22,22 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [
|
||||
Location.node.replace(activeLocation),
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileMutation", () => {
|
||||
it.live("writes an existing internal file and returns a stable result", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
@@ -54,9 +51,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({
|
||||
path: path.join("src", "nested", "hello.txt"),
|
||||
})
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
|
||||
@@ -73,12 +71,13 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const preserved = yield* access.resolve({ path: "preserved.txt" })
|
||||
const created = yield* access.resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
@@ -91,11 +90,12 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("writes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -111,7 +111,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
@@ -133,10 +133,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const firstPlan = yield* access.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* access.resolve({ path: "shared.txt" })
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
@@ -154,7 +154,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("shares transaction locks across Location service instances", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -183,7 +183,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -205,7 +205,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -222,10 +222,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
const firstPlan = yield* access.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* access.resolve({ path: "second.txt" })
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
|
||||
@@ -132,17 +132,9 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves compatible catalog providers through their own packages", () =>
|
||||
it.effect("keeps explicitly selected compatible packages generic for known provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const [providerID, native] of [
|
||||
["baseten", "baseten"],
|
||||
["cerebras", "cerebras"],
|
||||
["deepinfra", "deepinfra"],
|
||||
["deepseek", "deepseek"],
|
||||
["fireworks-ai", "fireworks"],
|
||||
["groq", "groq"],
|
||||
["togetherai", "togetherai"],
|
||||
] as const) {
|
||||
for (const providerID of ["baseten", "cerebras", "deepinfra", "deepseek", "fireworks-ai", "groq", "togetherai"]) {
|
||||
const selected = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
providerID: Provider.ID.make(providerID),
|
||||
@@ -150,7 +142,7 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
expect(String(selected.provider)).toBe(providerID)
|
||||
expect(selected.route.id).toBe(`${native}-chat`)
|
||||
expect(selected.route.id).toBe("openai-compatible-chat")
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://provider.example/v1/openai")
|
||||
const prepared = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" }))
|
||||
expect(prepared.body.messages).toEqual([{ role: "user", content: "Hello" }])
|
||||
@@ -475,7 +467,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("deepseek-chat")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(String(resolved.provider)).toBe("deepseek")
|
||||
expect(resolved.route.providerMetadataKey).toBe("deepseek")
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
|
||||
@@ -145,7 +145,11 @@ describe("OpenAIPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai"))
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
expect(provider.headers).toMatchObject({
|
||||
originator: "opencode",
|
||||
"chatgpt-account-id": "acct_123",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
})
|
||||
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(direct.hasHttpHooks).toBe(false)
|
||||
@@ -206,6 +210,8 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(model.capabilities.responsesWebsockets).toBe(true)
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
expect(direct.hasHttpHooks).toBe(false)
|
||||
expect(provider.headers).not.toHaveProperty("originator")
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
|
||||
@@ -25,6 +25,47 @@ const addPlugin = Effect.fn(function* () {
|
||||
yield* OpencodePlugin.effect(host)
|
||||
})
|
||||
|
||||
function consoleServer(orgID: string | null | undefined, unavailable = false) {
|
||||
const config: { authorization: string | null; orgID: string | null }[] = []
|
||||
const requests: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
requests.push(path)
|
||||
if (path === "/auth/device/code") {
|
||||
return Response.json({
|
||||
device_code: "device",
|
||||
user_code: "user",
|
||||
verification_uri_complete: "/device?user_code=user",
|
||||
expires_in: 60,
|
||||
interval: 0,
|
||||
})
|
||||
}
|
||||
if (path === "/auth/device/token") {
|
||||
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600, org_id: orgID })
|
||||
}
|
||||
if (unavailable && (path === "/api/user" || path === "/api/orgs")) {
|
||||
return new Response("Unavailable", { status: 503 })
|
||||
}
|
||||
if (path === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
|
||||
if (path === "/api/orgs") {
|
||||
return Response.json([
|
||||
{ id: "org-z", name: "Zebra" },
|
||||
{ id: "org-a", name: "Alpha" },
|
||||
])
|
||||
}
|
||||
if (path === "/api/v2/config") {
|
||||
config.push({ authorization: request.headers.get("authorization"), orgID: request.headers.get("x-org-id") })
|
||||
if (orgID === "org-missing") return new Response("Forbidden", { status: 403 })
|
||||
return Response.json({ providers: {} })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
return { server, config, requests }
|
||||
}
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
@@ -161,6 +202,119 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
for (const orgID of ["org-z", undefined, null, "org-missing"]) {
|
||||
it.live(`uses the device token organization during authorization: ${orgID}`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(orgID)),
|
||||
({ server, config }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("opencode")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answer: { server: server.url.origin },
|
||||
})
|
||||
const status = yield* eventually(
|
||||
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
|
||||
(status) => status.status !== "pending",
|
||||
)
|
||||
if (orgID === "org-missing") {
|
||||
expect(status).toMatchObject({
|
||||
status: "failed",
|
||||
message: "OpenCode organization not found: org-missing",
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
expect(config).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(status.status).toBe("complete")
|
||||
expect((yield* credentials.list(integrationID))[0]).toMatchObject({
|
||||
label: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
value: {
|
||||
type: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
accountID: "user",
|
||||
email: "user@example.com",
|
||||
orgID: orgID ?? "org-a",
|
||||
orgName: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => config.length),
|
||||
(count) => count > 0,
|
||||
)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: orgID ?? "org-a" }])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ orgID: "org-z" },
|
||||
{ orgID: undefined },
|
||||
{ orgID: null },
|
||||
{ orgID: "org-missing" },
|
||||
{ orgID: "org-z", unavailable: true },
|
||||
{ orgID: "org-a", unavailable: true },
|
||||
]) {
|
||||
it.live(
|
||||
`persists rotated credentials for ${scenario.orgID}${scenario.unavailable ? " with discovery unavailable" : ""}`,
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(scenario.orgID, scenario.unavailable)),
|
||||
({ server, config, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const initial = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
label: "Custom label",
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired-access",
|
||||
refresh: "old-refresh",
|
||||
expires: 0,
|
||||
metadata: { server: server.url.origin, orgID: "org-a", orgName: "Alpha", custom: "preserved" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const stored = required(yield* credentials.get(initial.id))
|
||||
expect(stored).toMatchObject({
|
||||
label: "Custom label",
|
||||
value: {
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
orgID: scenario.orgID ?? "org-a",
|
||||
orgName: scenario.orgID === "org-a" ? "Alpha" : (scenario.orgID ?? "Alpha"),
|
||||
custom: "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
expect(stored.value.expires).toBeGreaterThan(Date.now())
|
||||
if (scenario.orgID == null) expect(stored.value.metadata).toEqual(initial.value.metadata)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
|
||||
const integrations = yield* Integration.Service
|
||||
expect(
|
||||
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
|
||||
).toEqual(stored.value)
|
||||
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects non-HTTP OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
@@ -380,40 +534,23 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes legacy Console OpenAI variant bodies without changing native bodies", () =>
|
||||
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const variants = [
|
||||
{
|
||||
id: "legacy",
|
||||
body: { reasoningEffort: "high", reasoningSummary: "auto" },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "effort-only",
|
||||
body: { reasoningEffort: "low" },
|
||||
expected: { reasoning: { effort: "low" } },
|
||||
body: { reasoning: { effort: "low" } },
|
||||
},
|
||||
{
|
||||
id: "summary-only",
|
||||
body: { reasoningSummary: "detailed" },
|
||||
expected: { reasoning: { summary: "detailed" } },
|
||||
body: { reasoning: { summary: "detailed" } },
|
||||
},
|
||||
{
|
||||
id: "native",
|
||||
body: { reasoning: { effort: "high", summary: "auto" } },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "mixed",
|
||||
body: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { effort: "high", summary: "detailed" },
|
||||
},
|
||||
expected: { reasoning: { effort: "high", summary: "detailed" } },
|
||||
},
|
||||
{ id: "plain", body: {}, expected: {} },
|
||||
{ id: "plain", body: {} },
|
||||
]
|
||||
const models = {
|
||||
astra: {
|
||||
@@ -481,7 +618,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const projected = required(model.variants.find((item) => item.id === variant.id))
|
||||
expect(projected.body).toEqual({
|
||||
...variant.expected,
|
||||
...variant.body,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
@@ -495,7 +632,7 @@ describe("OpencodePlugin", () => {
|
||||
const request = required(requests.at(-1))
|
||||
expect(request.variant).toBe(variant.id)
|
||||
expect(request.body).toMatchObject({
|
||||
...variant.expected,
|
||||
...variant.body,
|
||||
model: "api-astra",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
@@ -508,9 +645,8 @@ describe("OpencodePlugin", () => {
|
||||
const compatible = required(yield* catalog.model.get(Provider.ID.make("compatible"), Model.ID.make("astra")))
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
for (const model of [compatible, override]) {
|
||||
expect(model.variants.find((variant) => variant.id === "legacy")?.body).toEqual({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
expect(model.variants.find((variant) => variant.id === "native")?.body).toEqual({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Session.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
ReadToolFileSystem.node,
|
||||
readToolNode,
|
||||
Tool.node,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionHistory } from "@opencode-ai/core/session/history"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionInbox.node,
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
PluginHooks.node,
|
||||
llmClient,
|
||||
]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const blocked = Deferred.makeUnsafe<void>()
|
||||
const hanging = Promise.withResolvers<Response>()
|
||||
const state = { failure: false, hang: false, calls: 0 }
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
const headers: Headers[] = []
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
state.calls++
|
||||
headers.push(request.headers)
|
||||
bodies.push(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)))(
|
||||
await request.text(),
|
||||
),
|
||||
)
|
||||
if (state.hang) {
|
||||
Deferred.doneUnsafe(blocked, Effect.void)
|
||||
return hanging.promise
|
||||
}
|
||||
if (state.failure)
|
||||
return Response.json(
|
||||
{ error: { message: "fixture rate limit", type: "rate_limit_error" } },
|
||||
{ status: 429 },
|
||||
)
|
||||
const checkpoint = {
|
||||
type: "compaction",
|
||||
id: `cmp_${state.calls}`,
|
||||
encrypted_content: `encrypted_${state.calls}`,
|
||||
}
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return Response.json({
|
||||
id: "compact_endpoint",
|
||||
object: "response.compaction",
|
||||
output: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "endpoint retained" }] },
|
||||
checkpoint,
|
||||
],
|
||||
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
|
||||
})
|
||||
const output = JSON.stringify(bodies.at(-1)).includes("compaction_trigger") ? [checkpoint] : []
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: `resp_${state.calls}`,
|
||||
status: "completed",
|
||||
output,
|
||||
usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 },
|
||||
},
|
||||
})}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.sync(() => {
|
||||
hanging.resolve(new Response("cancelled"))
|
||||
void server.stop(true)
|
||||
}),
|
||||
)
|
||||
const native = OpenAI.configure({ apiKey: "fixture", baseURL: server.url.toString() }).responses("gpt-5.4-mini")
|
||||
const model = SessionRunnerModel.resolved(
|
||||
endpoint
|
||||
? LanguageModel.update(native, {
|
||||
route: native.route.with({ compact: { endpoint: native.route.compact.endpoint } }),
|
||||
})
|
||||
: native,
|
||||
{
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
compaction: { mode: "provider" },
|
||||
},
|
||||
)
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
slug: "native-compaction",
|
||||
version: "test",
|
||||
})
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die("Missing fixture session")
|
||||
const instructions = Instructions.make({
|
||||
key: Instructions.Key.make("test/native"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed("Current instructions"),
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-test-hook"] = event.kind
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "http.request", (event) =>
|
||||
Effect.sync(() => event.request.headers.set("x-http-hook", event.kind)),
|
||||
)
|
||||
const prompt = Effect.fnUntraced(function* (text: string, synthetic = false) {
|
||||
const id = SessionMessage.ID.create()
|
||||
yield* inbox.admit({
|
||||
id,
|
||||
sessionID,
|
||||
item: { type: synthetic ? "synthetic" : "user", payload: { text }, delivery: "steer" },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
|
||||
})
|
||||
const load = Effect.gen(function* () {
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
sessionID,
|
||||
instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
return {
|
||||
session,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
instructionUpdate: history.instructionUpdate,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
tools: {
|
||||
definitions: [
|
||||
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
||||
],
|
||||
execute: () => Effect.die("Compaction must never dispatch tools"),
|
||||
},
|
||||
}
|
||||
})
|
||||
const compact = Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: SessionMessage.ID.create(),
|
||||
resolveContext: () => load,
|
||||
prepare: requests.prepare,
|
||||
})
|
||||
})
|
||||
const checkpoint = Effect.gen(function* () {
|
||||
const messages = (yield* load).messages
|
||||
const last = messages.findLast((message) => message.type === "compaction" && message.status === "completed")
|
||||
if (last?.type !== "compaction" || last.status !== "completed" || !last.providerContext)
|
||||
return yield* Effect.die("Missing native checkpoint")
|
||||
expect(last.summary).toBe("")
|
||||
expect(last.recent).toBe("")
|
||||
return last.providerContext
|
||||
})
|
||||
return {
|
||||
compact,
|
||||
checkpoint,
|
||||
prompt,
|
||||
load,
|
||||
requests,
|
||||
bodies,
|
||||
headers,
|
||||
state,
|
||||
blocked,
|
||||
sessionID,
|
||||
store,
|
||||
hooks,
|
||||
model,
|
||||
}
|
||||
})
|
||||
|
||||
it.live(
|
||||
"manual trigger persists and continues, retains earlier users repeatedly, and preserves context on failure/cancellation",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
yield* fixture.prompt("First real user request")
|
||||
yield* fixture.prompt("Synthetic context, not a user request", true)
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
const first = yield* fixture.checkpoint
|
||||
expect(SessionProviderContext.decode(first).map((message) => message.role)).toEqual(["user", "assistant"])
|
||||
expect(JSON.stringify(first.messages)).not.toContain("Synthetic context")
|
||||
expect(fixture.bodies[0]).toMatchObject({
|
||||
input: expect.arrayContaining([{ type: "compaction_trigger" }]),
|
||||
tools: [expect.objectContaining({ name: "read" })],
|
||||
})
|
||||
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
|
||||
expect(fixture.headers[0]?.get("x-test-hook")).toBe("compaction")
|
||||
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
|
||||
yield* fixture.prompt("Second real user request")
|
||||
const context = yield* fixture.load
|
||||
const prepared = yield* fixture.requests.prepare({
|
||||
kind: "primary",
|
||||
scope: { session: context.session, model: context.model, agentID: context.agent.id, tools: context.tools },
|
||||
transcript: SessionModelRequest.baseTranscript({ ...context, agent: context.agent.info }),
|
||||
})
|
||||
const client = yield* LLMClient.Service
|
||||
yield* client.generate(prepared.request, prepared.options)
|
||||
expect(JSON.stringify(fixture.bodies[1])).toContain("encrypted_1")
|
||||
expect(JSON.stringify(fixture.bodies[1])).toContain("Current instructions")
|
||||
expect(JSON.stringify(fixture.bodies[1])).toContain("Second real user request")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
const second = yield* fixture.checkpoint
|
||||
expect(
|
||||
SessionProviderContext.decode(second)
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.content),
|
||||
).toEqual([[Message.text("First real user request")], [Message.text("Second real user request")]])
|
||||
expect(JSON.stringify(second.messages)).not.toContain("encrypted_1")
|
||||
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 40, output: 8 } })
|
||||
// Nothing new since the checkpoint is not compactable, exactly like a fresh local summary.
|
||||
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "compaction.unavailable" } })
|
||||
yield* fixture.prompt("Third real user request")
|
||||
fixture.state.failure = true
|
||||
expect(yield* fixture.compact).toMatchObject({ status: "failed", error: { type: "provider.rate-limit" } })
|
||||
expect(fixture.state.calls).toBe(4)
|
||||
expect(yield* fixture.checkpoint).toEqual(second)
|
||||
fixture.state.hang = true
|
||||
const pending = yield* fixture.compact.pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(fixture.blocked)
|
||||
yield* Fiber.interrupt(pending)
|
||||
expect(fixture.state.calls).toBe(5)
|
||||
expect(yield* fixture.checkpoint).toEqual(second)
|
||||
}),
|
||||
15000,
|
||||
)
|
||||
|
||||
it.live("endpoint-only compaction keeps the provider replacement unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup(true)
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
const replacement = SessionProviderContext.decode(yield* fixture.checkpoint)
|
||||
expect(replacement[0]?.content).toEqual([Message.text("endpoint retained")])
|
||||
expect(JSON.stringify(replacement)).not.toContain("Original user")
|
||||
expect(fixture.state.calls).toBe(1)
|
||||
expect(fixture.headers[0]?.get("x-http-hook")).toBe("compaction")
|
||||
expect(fixture.bodies[0]).not.toHaveProperty("context_management")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup()
|
||||
yield* fixture.prompt("Original user")
|
||||
yield* fixture.hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.baseURL = "https://another.example/v1"
|
||||
}),
|
||||
)
|
||||
expect(yield* fixture.compact).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.unsupported-operation" },
|
||||
})
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
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"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
const user = (text: string) =>
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text,
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
const newest = {
|
||||
...user("x".repeat(63_000 * 4)),
|
||||
files: [{ mime: "image/png", data: "aGVsbG8=", source: { type: "inline" as const } }],
|
||||
}
|
||||
expect(SessionCompaction.retainUsers([user("old"), newest], model, 64_000)).toEqual([])
|
||||
expect(
|
||||
SessionCompaction.retainUsers([user("x".repeat(63_000 * 4)), { ...newest, text: "new" }], model, 64_000),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionHistory } from "@opencode-ai/core/session/history"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 128_000, output: 4096 },
|
||||
},
|
||||
)
|
||||
const target = SessionProviderContext.provenance(model)
|
||||
if (!target) throw new Error("Fixture must have a concrete endpoint")
|
||||
const replacement = [
|
||||
Message.user("retained request"),
|
||||
Message.assistant(
|
||||
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
|
||||
),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
const sessionID = SessionSchema.ID.make("ses_provider_context")
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
slug: "provider-context",
|
||||
version: "test",
|
||||
})
|
||||
const state = { value: "initial instructions" }
|
||||
const instructions = Instructions.make({
|
||||
key: Instructions.Key.make("test/context"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.sync(() => state.value),
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
|
||||
const prompt = Effect.fnUntraced(function* (text: string) {
|
||||
const id = SessionMessage.ID.create()
|
||||
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
|
||||
return id
|
||||
})
|
||||
const compact = (context?: SessionProviderContext.Info) =>
|
||||
bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: context ? "" : "local summary",
|
||||
recent: "",
|
||||
providerContext: context,
|
||||
})
|
||||
const load = (boundary: SessionHistory.Boundary) =>
|
||||
SessionHistory.entriesForRunner(database.db, sessionID, instructions, boundary)
|
||||
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
|
||||
})
|
||||
|
||||
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
|
||||
const messages = [
|
||||
...replacement,
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
|
||||
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
|
||||
]
|
||||
const context = SessionProviderContext.encode(providerContext.provenance, messages)
|
||||
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
|
||||
const decoded = SessionProviderContext.decode(stored)
|
||||
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
|
||||
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
|
||||
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
|
||||
],
|
||||
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
|
||||
}),
|
||||
])
|
||||
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
|
||||
providerMetadata: { openai: { type: "message" } },
|
||||
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
|
||||
})
|
||||
expect(() =>
|
||||
SessionProviderContext.decode({
|
||||
...context,
|
||||
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
|
||||
expect(
|
||||
SessionProviderContext.compatible(
|
||||
providerContext.provenance,
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
for (const changed of [
|
||||
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
|
||||
{
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
|
||||
}),
|
||||
},
|
||||
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
|
||||
])
|
||||
expect(
|
||||
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
|
||||
).toBe(false)
|
||||
const privateEndpoint = SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
|
||||
}),
|
||||
})
|
||||
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
|
||||
expect(
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
|
||||
}),
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.effect(
|
||||
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("original request")
|
||||
s.state.value = "changed instructions"
|
||||
yield* s.prepare
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
const completed = yield* s.compact(providerContext)
|
||||
s.state.value = "newest instructions"
|
||||
yield* s.prepare
|
||||
yield* s.prompt("continue")
|
||||
|
||||
const verify = Effect.gen(function* () {
|
||||
expect(
|
||||
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
|
||||
).toMatchObject({
|
||||
epoch_start: completed.durable.seq,
|
||||
initial_values: { "test/context": Instructions.hash("changed instructions") },
|
||||
current_values: { "test/context": Instructions.hash("newest instructions") },
|
||||
})
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
),
|
||||
).toEqual([
|
||||
...replacement,
|
||||
Message.system("newest instructions"),
|
||||
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
|
||||
])
|
||||
for (const incompatible of [
|
||||
"local" as const,
|
||||
{ ...providerContext.provenance, modelID: "other" },
|
||||
{ ...providerContext.provenance, provider: "other" },
|
||||
]) {
|
||||
const expanded = yield* s.load(incompatible)
|
||||
expect(expanded.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
expanded.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
).map((message) => message.content),
|
||||
).toEqual([
|
||||
[Message.text("original request")],
|
||||
[Message.text("newest instructions")],
|
||||
[Message.text("continue")],
|
||||
])
|
||||
}
|
||||
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
|
||||
expect(preview.initial).toBe("changed instructions")
|
||||
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"system",
|
||||
"compaction",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
})
|
||||
yield* verify
|
||||
const recorded = yield* s.db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
|
||||
yield* s.bus.remove(sessionID)
|
||||
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
|
||||
for (const event of recorded)
|
||||
yield* s.bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})
|
||||
yield* verify
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("before local")
|
||||
s.state.value = "local baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact()
|
||||
yield* s.prompt("after local")
|
||||
yield* s.compact(providerContext)
|
||||
yield* s.prompt("after native")
|
||||
s.state.value = "new native baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
|
||||
s.state.value = "post-epoch update"
|
||||
yield* s.prepare
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("new native baseline")
|
||||
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
|
||||
expect(native.entries[0]?.message).toMatchObject({ providerContext })
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
).filter((message) => message.role === "system"),
|
||||
).toEqual([Message.system("post-epoch update")])
|
||||
const local = yield* s.load("local")
|
||||
expect(local.initial).toBe("new native baseline")
|
||||
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
|
||||
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed persisted native windows instead of silently dropping them", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.compact({ ...providerContext, messages: [{ role: "invalid", content: [] }] })
|
||||
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageDecodeError",
|
||||
})
|
||||
const store = yield* SessionStore.Service
|
||||
expect(yield* store.context(sessionID).pipe(Effect.flip)).toMatchObject({ _tag: "Session.MessageDecodeError" })
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -40,6 +41,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -1435,6 +1437,92 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario(
|
||||
"restores installed native context with auto disabled and preserves it across fork and revert",
|
||||
function* (s) {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => editor.configure({ auto: false }))
|
||||
yield* s.runPrompt("Original request")
|
||||
s.systemBaseline = "Checkpoint instructions"
|
||||
yield* s.runPrompt("Before checkpoint")
|
||||
const target = SessionProviderContext.provenance({
|
||||
model: s.currentModel,
|
||||
ref: Model.Ref.make({
|
||||
id: Model.ID.make(s.currentModel.id),
|
||||
providerID: Provider.ID.make(s.currentModel.provider),
|
||||
}),
|
||||
})
|
||||
if (!target) throw new Error("Expected concrete fixture endpoint")
|
||||
const replacement = [
|
||||
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "",
|
||||
recent: "",
|
||||
providerContext,
|
||||
})
|
||||
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
|
||||
if (!checkpoint) throw new Error("Expected checkpoint")
|
||||
|
||||
s.systemBaseline = "Newest instructions"
|
||||
const after = yield* s.runPrompt("After checkpoint")
|
||||
const continued = s.requests.at(-1)
|
||||
if (!continued) throw new Error("Expected continuation request")
|
||||
expect(continued.messages[0]).toEqual(replacement[0])
|
||||
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
|
||||
expect(systemTexts(continued)).toEqual(["Newest instructions"])
|
||||
|
||||
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const original = s.currentModel
|
||||
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
s.currentModel = original
|
||||
|
||||
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
|
||||
yield* s.runPrompt("After revert")
|
||||
expect(
|
||||
s.requests
|
||||
.at(-1)
|
||||
?.messages.flatMap((message) => message.content)
|
||||
.some((part) => part.type === "compaction"),
|
||||
).toBe(false)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.baseURL = "https://another-deployment.example/v1"
|
||||
}),
|
||||
)
|
||||
const before = s.requests.length
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
|
||||
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
expect(s.requests).toHaveLength(before)
|
||||
},
|
||||
)
|
||||
|
||||
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
s.systemBaseline = "Changed context"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -91,7 +91,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(registry)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Effect, Exit, Layer, Result } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { location } from "./fixture/location"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
@@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -47,7 +47,7 @@ const readCalls: {
|
||||
page: ReadToolFileSystem.PageInput
|
||||
}[] = []
|
||||
const listCalls: AbsolutePath[] = []
|
||||
let resolveFailure: unknown
|
||||
let readDefect: unknown
|
||||
let directoryEntries: string[] = []
|
||||
let directoryEntryDetails: Environment.DirEntry[] = []
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
|
||||
@@ -69,7 +69,7 @@ const reader = Layer.succeed(
|
||||
},
|
||||
read: (input, resource, page = {}) => {
|
||||
readCalls.push({ input, page })
|
||||
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
|
||||
if (readDefect !== undefined) return Effect.die(readDefect)
|
||||
if (readOverride) return readOverride(input, resource, page)
|
||||
if (readFailure !== undefined) return Effect.fail(readFailure)
|
||||
return Effect.succeed(readResult)
|
||||
@@ -77,13 +77,14 @@ const reader = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
let deniedResource: string | undefined
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
allow && !input.resources.some((resource) => resource === deniedResource)
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
@@ -112,30 +113,6 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
LocationMutation.Service.of({
|
||||
resolve: (input) => {
|
||||
const absolute = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
||||
const directory = path.dirname(absolute)
|
||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||
return Effect.succeed({
|
||||
absolute,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory" as const,
|
||||
directory,
|
||||
resource: externalResource,
|
||||
save: externalResource,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
const unavailableImage = Layer.mock(Image.Service, {
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
})
|
||||
@@ -146,7 +123,6 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
Image.node.replace(imageLayer),
|
||||
LocationMutation.node.replace(mutation),
|
||||
FSUtil.node.replace(testFileSystem),
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
|
||||
@@ -165,7 +141,8 @@ describe("ReadTool", () => {
|
||||
readCalls.length = 0
|
||||
listCalls.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
deniedResource = undefined
|
||||
readDefect = undefined
|
||||
directoryEntries = []
|
||||
directoryEntryDetails = []
|
||||
readResult = {
|
||||
@@ -620,18 +597,21 @@ describe("ReadTool", () => {
|
||||
|
||||
it.effect("preserves unexpected filesystem defects", () =>
|
||||
Effect.gen(function* () {
|
||||
resolveFailure = new Error("unexpected")
|
||||
readDefect = new Error("unexpected")
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
const exit = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
|
||||
}).pipe(Effect.exit)
|
||||
expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect)
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -721,6 +701,57 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers an external filename without repeating directory approval", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(path.parse(process.cwd()).root, "external-read")
|
||||
const requested = path.join(directory, "report final.txt")
|
||||
const recovered = path.join(directory, "report\u202ffinal.txt")
|
||||
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
|
||||
readOverride = (input) =>
|
||||
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] },
|
||||
{ action: "read", resources: [requested.replaceAll("\\", "/")] },
|
||||
{ action: "read", resources: [recovered.replaceAll("\\", "/")] },
|
||||
])
|
||||
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not read a recovered filename denied by its own read rules", () =>
|
||||
Effect.gen(function* () {
|
||||
const requested = path.join(process.cwd(), "report final.txt")
|
||||
const recovered = path.join(process.cwd(), "report\u202ffinal.txt")
|
||||
deniedResource = path.basename(recovered)
|
||||
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
|
||||
readOverride = (input) =>
|
||||
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } },
|
||||
}),
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "read", resources: [path.basename(requested)] },
|
||||
{ action: "read", resources: [path.basename(recovered)] },
|
||||
])
|
||||
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover ambiguous files", () =>
|
||||
Effect.gen(function* () {
|
||||
const requested = "report final.txt"
|
||||
@@ -860,30 +891,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unexpected resolution defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
resolveFailure = new Error("missing")
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new ReadToolFileSystem.TextPage({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Permission.node,
|
||||
Session.node,
|
||||
Job.node,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(registry)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
write: (target, content) =>
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3"
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@brendonovich/vite-plugin-opencode": "0.1.3",
|
||||
@@ -37,6 +38,8 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin-browser": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -47,12 +50,14 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "8.2.2"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { $ } from "bun"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export async function prepareDevElectron() {
|
||||
const electron = dirname(fileURLToPath(import.meta.resolve("electron/package.json")))
|
||||
const icon = Bun.file(join(import.meta.dirname, "../icons/dev/icon.icns"))
|
||||
const hash = new Bun.CryptoHasher("sha256")
|
||||
.update(electron)
|
||||
.update(process.arch)
|
||||
.update(await Bun.file(join(electron, "package.json")).text())
|
||||
.update(await icon.arrayBuffer())
|
||||
.update(await Bun.file(import.meta.filename).text())
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
const root = join(import.meta.dirname, "../node_modules/.cache/opencode-dev", hash)
|
||||
const bundle = join(root, "OpenCode Dev.app")
|
||||
// Electron uses the executable's name to distinguish development from packaged apps.
|
||||
const executable = join(bundle, "Contents/MacOS/Electron")
|
||||
if (await Bun.file(join(root, "ready")).exists()) return executable
|
||||
|
||||
// macOS reads the Dock and app-switcher identity from the bundle, even after app.setName().
|
||||
await $`mkdir -p ${root}`
|
||||
await $`ditto ${join(electron, "dist/Electron.app")} ${bundle}`
|
||||
const plist = join(bundle, "Contents/Info.plist")
|
||||
for (const key of ["CFBundleName", "CFBundleDisplayName"]) {
|
||||
await $`plutil -replace ${key} -string ${"OpenCode Dev"} ${plist}`
|
||||
}
|
||||
await $`plutil -replace CFBundleIdentifier -string ai.opencode.desktop.dev ${plist}`
|
||||
await Bun.write(join(bundle, "Contents/Resources/electron.icns"), icon)
|
||||
// Changing the bundle resources invalidates Electron's signature.
|
||||
await $`codesign --force --deep --sign - ${bundle}`
|
||||
await Bun.write(join(root, "ready"), "")
|
||||
return executable
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { prepareDevElectron } from "./dev-electron"
|
||||
import { downloadCliToResources, windowsify } from "./utils"
|
||||
|
||||
type ServerSource = { type: "build" } | { type: "download"; version: string }
|
||||
@@ -23,6 +24,7 @@ async function prepareDesktop() {
|
||||
$`bun run install-electron`,
|
||||
$`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`,
|
||||
])
|
||||
if (process.platform === "darwin") process.env.ELECTRON_EXEC_PATH = await prepareDevElectron()
|
||||
}
|
||||
|
||||
function selectOptions(): DevOptions {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { destinationOrigin } from "./browser/policy"
|
||||
|
||||
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
|
||||
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
|
||||
expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000")
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,test",
|
||||
"https://user:pass@example.com",
|
||||
]) {
|
||||
expect(destinationOrigin(url)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode-ai/app/desktop"
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Deferred, Effect, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
|
||||
import { createBrowserPage, type BrowserPage } from "./browser-chromium"
|
||||
import { browserFailure } from "./browser/errors"
|
||||
import { createBrowserNetwork, type BrowserNetwork } from "./browser/network"
|
||||
import { destinationOrigin } from "./browser/policy"
|
||||
import { emitIpcEvent } from "./ipc-events"
|
||||
import { SidecarCredentials } from "./service/sidecar-credentials"
|
||||
|
||||
type Entry = {
|
||||
bindingID: string
|
||||
win: BrowserWindow
|
||||
abort: AbortController
|
||||
registered: PromiseWithResolvers<void>
|
||||
requests: Map<string, { abort: AbortController; tabID?: Browser.TabID }>
|
||||
report?: (event: BrowserPaneEvent["event"]) => void
|
||||
cleanup?: () => void
|
||||
pages: Map<Browser.TabID, BrowserPage>
|
||||
focusedTabID: Browser.TabID | null
|
||||
partition: string
|
||||
lastState?: string
|
||||
network?: BrowserNetwork
|
||||
}
|
||||
|
||||
export function createBrowserPane() {
|
||||
const entries = new Map<string, Entry>()
|
||||
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
|
||||
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
|
||||
let disposed = false
|
||||
return {
|
||||
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
|
||||
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
|
||||
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
|
||||
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
|
||||
const sessionID = SessionID.make(target.sessionID)
|
||||
const entry: Entry = {
|
||||
bindingID,
|
||||
win,
|
||||
abort: new AbortController(),
|
||||
registered: Promise.withResolvers(),
|
||||
requests: new Map(),
|
||||
pages: new Map(),
|
||||
focusedTabID: null,
|
||||
partition: `opencode-browser-${crypto.randomUUID()}`,
|
||||
}
|
||||
// "unsupported" means the server has no browser plugin; the renderer stops retrying.
|
||||
let reason: "browser.pane.unsupported" | "browser.pane.replaced" | undefined
|
||||
const stop = () => close(entry, reason)
|
||||
const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
|
||||
if (event.isMainFrame && !event.isSameDocument) stop()
|
||||
}
|
||||
win.webContents.once("destroyed", stop)
|
||||
win.webContents.on("did-start-navigation", navigate)
|
||||
entry.cleanup = () => {
|
||||
if (win.isDestroyed()) return
|
||||
win.webContents.off("destroyed", stop)
|
||||
win.webContents.off("did-start-navigation", navigate)
|
||||
}
|
||||
entries.set(bindingID, entry)
|
||||
void runtime
|
||||
.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
// The renderer never holds the managed sidecar's password; Node requests bypass
|
||||
// the webRequest header injection, so resolve the credential here in main.
|
||||
const authorization = target.endpoint.password
|
||||
? `Basic ${Buffer.from(`${target.endpoint.username ?? "opencode"}:${target.endpoint.password}`).toString("base64")}`
|
||||
: SidecarCredentials.authorization(SidecarCredentials.get(), target.endpoint.url)
|
||||
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
authorization
|
||||
? HttpClient.mapRequest(http, HttpClientRequest.setHeader("authorization", authorization))
|
||||
: http,
|
||||
),
|
||||
)
|
||||
const session = yield* client.session.get({ sessionID })
|
||||
const options = {
|
||||
location: { directory: session.location.directory, workspace: session.location.workspaceID },
|
||||
}
|
||||
const attachment = { sessionID, connectionID: crypto.randomUUID() }
|
||||
const rpc = client.rpc(Browser.Definition)
|
||||
entry.network = yield* createBrowserNetwork({
|
||||
rpc,
|
||||
attachment,
|
||||
location: options.location,
|
||||
partition: entry.partition,
|
||||
})
|
||||
const connected = yield* Deferred.make<void>()
|
||||
const outbound = yield* Queue.unbounded<Effect.Effect<void>>()
|
||||
// A send that fails because the server already replaced or closed this attachment must
|
||||
// not decide the close reason; only the attach call's outcome does.
|
||||
const send = (effect: Effect.Effect<unknown, unknown>) =>
|
||||
Queue.offerUnsafe(
|
||||
outbound,
|
||||
effect.pipe(Effect.catchCause((cause) => Effect.logWarning("Browser send failed", cause))),
|
||||
)
|
||||
const reply = (requestID: string, outcome: Browser.Outcome) =>
|
||||
send(
|
||||
rpc.result({ ...attachment, requestID, outcome: Schema.encodeSync(Browser.Outcome)(outcome) }, options),
|
||||
)
|
||||
// Report state before publishing it locally or completing a command. The server's copy of
|
||||
// the inventory resolves every tab ID, so a state is retried until it arrives or the
|
||||
// attachment ends; the results queued behind it then never name a tab the server lacks.
|
||||
// "unavailable" means the server already dropped this attachment, which attach reports.
|
||||
entry.report = (event) => {
|
||||
const local = Effect.sync(() => publish(entry, event))
|
||||
if (event.type !== "state") return send(local)
|
||||
send(
|
||||
rpc.state({ ...attachment, state: event.state ?? { tabs: [], focusedTabID: null } }, options).pipe(
|
||||
Effect.retry({
|
||||
while: (error) => !("type" in error && error.type === "unavailable"),
|
||||
schedule: Schedule.min([Schedule.exponential("250 millis"), Schedule.spaced("10 seconds")]),
|
||||
}),
|
||||
Effect.ensuring(local),
|
||||
),
|
||||
)
|
||||
}
|
||||
const receive = client.event.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === "server.connected") {
|
||||
yield* Deferred.succeed(connected, undefined)
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type !== "rpc.experimental.browser.control" ||
|
||||
event.data.connectionID !== attachment.connectionID
|
||||
)
|
||||
return
|
||||
const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
reason = "browser.pane.unsupported"
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (message.type === "attached") return entry.registered.resolve()
|
||||
if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort.abort()
|
||||
const abort = new AbortController()
|
||||
entry.requests.set(message.requestID, { abort })
|
||||
yield* rpc.command({ ...attachment, requestID: message.requestID }, options).pipe(
|
||||
Effect.flatMap((command) =>
|
||||
Effect.promise(async () => {
|
||||
entry.requests.set(message.requestID, {
|
||||
abort,
|
||||
...("tabID" in command.action ? { tabID: command.action.tabID } : {}),
|
||||
})
|
||||
reply(
|
||||
message.requestID,
|
||||
await execute(entry, command, abort.signal).then(
|
||||
(result) => ({ type: "success" as const, result }),
|
||||
(error: unknown) => browserFailure(command.action, error),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(() => entry.requests.delete(message.requestID))),
|
||||
// An operation this desktop cannot decode comes from a newer plugin; answer it so
|
||||
// the agent does not wait out the server's timeout.
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => {
|
||||
if (!Schema.isSchemaError(error)) return
|
||||
reply(message.requestID, {
|
||||
type: "failure",
|
||||
code: "unsupported",
|
||||
message:
|
||||
"This desktop app does not support the requested browser operation. Ask the user to update the desktop app, or use another operation.",
|
||||
})
|
||||
}),
|
||||
),
|
||||
// A request that vanished (cancelled before retrieval) fails only that request.
|
||||
// Transport loss surfaces through the event stream and attach call instead.
|
||||
Effect.catchCause((cause) =>
|
||||
abort.signal.aborted ? Effect.void : Effect.logWarning("Browser command failed", cause),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.raceAllFirst([
|
||||
receive,
|
||||
Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)),
|
||||
Deferred.await(connected).pipe(
|
||||
Effect.andThen(rpc.attach({ ...attachment, version: 4 }, options)),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
if (result === "replaced") reason = "browser.pane.replaced"
|
||||
}),
|
||||
),
|
||||
),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => {
|
||||
const type = error instanceof Object && "type" in error ? error.type : undefined
|
||||
if (type === "rpc.unavailable" || type === "rpc.method_not_found" || type === "rpc.invalid_input")
|
||||
reason = "browser.pane.unsupported"
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(stop)),
|
||||
),
|
||||
{ signal: entry.abort.signal },
|
||||
)
|
||||
.catch(stop)
|
||||
const timeout = setTimeout(stop, 15_000)
|
||||
await entry.registered.promise.finally(() => clearTimeout(timeout))
|
||||
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
|
||||
publishState(entry)
|
||||
},
|
||||
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
|
||||
const entry = owned(win, bindingID)
|
||||
if (!value) return entry.pages.forEach((page) => page.setVisible(false))
|
||||
const page = entry.pages.get(value.tabID)
|
||||
if (!page) return
|
||||
const bounds = value.bounds
|
||||
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
page.setVisible(false)
|
||||
return
|
||||
}
|
||||
entry.pages.forEach((other) => {
|
||||
if (other !== page) other.setVisible(false)
|
||||
})
|
||||
page.layout(bounds, value.background, value.radius)
|
||||
page.setVisible(true)
|
||||
},
|
||||
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
|
||||
const entry = owned(win, bindingID)
|
||||
await execute(entry, { action: command, files: [] }, new AbortController().signal)
|
||||
},
|
||||
async close(win: BrowserWindow, bindingID: string) {
|
||||
close(owned(win, bindingID))
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true
|
||||
entries.forEach((entry) => close(entry))
|
||||
await runtime.dispose()
|
||||
},
|
||||
}
|
||||
|
||||
function owned(win: BrowserWindow, bindingID: string) {
|
||||
const entry = entries.get(bindingID)
|
||||
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
|
||||
return entry
|
||||
}
|
||||
|
||||
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
|
||||
}
|
||||
|
||||
function close(entry: Entry, reason = "browser.pane.registration.closed") {
|
||||
if (entries.get(entry.bindingID) !== entry) return
|
||||
entry.report = undefined
|
||||
entry.requests.forEach((request) => request.abort.abort())
|
||||
entry.requests.clear()
|
||||
entry.pages.forEach((page) => {
|
||||
void page.dispose().catch(() => undefined)
|
||||
})
|
||||
entry.pages.clear()
|
||||
entry.focusedTabID = null
|
||||
publishState(entry, reason)
|
||||
entries.delete(entry.bindingID)
|
||||
entry.registered.reject(new Error("browser.pane.registration.closed"))
|
||||
entry.cleanup?.()
|
||||
entry.abort.abort()
|
||||
}
|
||||
|
||||
async function closePage(entry: Entry, tabID: Browser.TabID, error?: string) {
|
||||
const page = entry.pages.get(tabID)
|
||||
if (!page)
|
||||
throw new Error(
|
||||
"This tab is no longer available. Call browser.tabs.list({}) and use an existing tabID from this session.",
|
||||
)
|
||||
const focused = entry.focusedTabID === tabID
|
||||
entry.requests.forEach((request) => {
|
||||
if (request.tabID === tabID) request.abort.abort()
|
||||
})
|
||||
entry.pages.delete(tabID)
|
||||
if (focused) entry.focusedTabID = entry.pages.keys().next().value ?? null
|
||||
await page.dispose()
|
||||
publishState(entry, error)
|
||||
}
|
||||
|
||||
function publishState(entry: Entry, error?: string) {
|
||||
const event = {
|
||||
type: "state" as const,
|
||||
state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID },
|
||||
...(error === undefined ? {} : { error }),
|
||||
}
|
||||
const next = JSON.stringify(event)
|
||||
if (entry.lastState === next) return
|
||||
entry.lastState = next
|
||||
report(entry, event)
|
||||
}
|
||||
|
||||
function report(entry: Entry, event: BrowserPaneEvent["event"]) {
|
||||
if (entry.report) return entry.report(event)
|
||||
publish(entry, event)
|
||||
}
|
||||
|
||||
function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) {
|
||||
if (!entry.network) throw new Error("Browser network is not ready; no tab was opened.")
|
||||
const id = Browser.TabID.make(`tab_${crypto.randomUUID()}`)
|
||||
const fail = () => {
|
||||
if (entry.pages.has(id)) void closePage(entry, id, "page_crashed").catch(() => undefined)
|
||||
}
|
||||
const page = createBrowserPage(entry.win, {
|
||||
id,
|
||||
partition: entry.partition,
|
||||
network: entry.network,
|
||||
initialize,
|
||||
popupOptions,
|
||||
fail,
|
||||
publish: (error) => {
|
||||
if (entry.pages.has(id)) publishState(entry, error)
|
||||
},
|
||||
popup: (popupOptions) => {
|
||||
const popup = create(entry, false, popupOptions)
|
||||
focus(entry, popup.state().id)
|
||||
return popup.contents
|
||||
},
|
||||
})
|
||||
entry.pages.set(id, page)
|
||||
void page.ready
|
||||
.then(() => {
|
||||
if (entry.pages.get(id) === page) publishState(entry)
|
||||
})
|
||||
.catch(fail)
|
||||
return page
|
||||
}
|
||||
|
||||
async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) {
|
||||
const action = command.action
|
||||
const state = () => ({
|
||||
tabs: Array.from(entry.pages.values(), (page) => page.state()),
|
||||
focusedTabID: entry.focusedTabID,
|
||||
})
|
||||
if (signal.aborted)
|
||||
throw new Error(
|
||||
"Browser request was cancelled. Do not repeat a mutating action until you have inspected its outcome.",
|
||||
)
|
||||
if (action.type === "tabs.list") return { value: state(), files: [] }
|
||||
if (action.type === "tabs.open") {
|
||||
const page = create(entry)
|
||||
if (action.focus !== false) focus(entry, page.state().id)
|
||||
await page.ready
|
||||
await page.execute(
|
||||
{ action: { type: "navigate", tabID: page.state().id, url: action.url ?? "about:blank" }, files: [] },
|
||||
signal,
|
||||
)
|
||||
publishState(entry)
|
||||
return { value: page.state(), files: [] }
|
||||
}
|
||||
const page = entry.pages.get(action.tabID)
|
||||
if (!page)
|
||||
throw new Error(
|
||||
"Browser tab is unavailable. Call browser.tabs.list({}) and use an existing tabID from this session; a closed tab is not replaced automatically.",
|
||||
)
|
||||
if (action.type === "tabs.focus") {
|
||||
focus(entry, action.tabID)
|
||||
return { value: page.state(), files: [] }
|
||||
}
|
||||
if (action.type === "tabs.close") {
|
||||
await closePage(entry, action.tabID)
|
||||
return { value: state(), files: [] }
|
||||
}
|
||||
await page.ready
|
||||
const result = await page.execute(command, signal)
|
||||
publishState(entry)
|
||||
return result
|
||||
}
|
||||
|
||||
function focus(entry: Entry, tabID: Browser.TabID) {
|
||||
entry.focusedTabID = tabID
|
||||
publishState(entry)
|
||||
report(entry, { type: "focus", tabID })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
const number = Schema.Finite
|
||||
const Trace = Schema.Struct({
|
||||
traceEvents: Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.optionalKey(Schema.String),
|
||||
ph: Schema.optionalKey(Schema.String),
|
||||
pid: Schema.optionalKey(number),
|
||||
tid: Schema.optionalKey(number),
|
||||
ts: Schema.optionalKey(number),
|
||||
dur: Schema.optionalKey(number),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const Cpu = Schema.Struct({
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
nodes: Schema.Array(
|
||||
Schema.Struct({
|
||||
id: number,
|
||||
callFrame: Schema.Struct({ functionName: Schema.String, url: Schema.String, lineNumber: number }),
|
||||
}),
|
||||
),
|
||||
samples: Schema.optionalKey(Schema.Array(number)),
|
||||
timeDeltas: Schema.optionalKey(Schema.Array(number)),
|
||||
})
|
||||
const Heap = Schema.Struct({
|
||||
snapshot: Schema.Struct({
|
||||
meta: Schema.Struct({
|
||||
node_fields: Schema.Array(Schema.String),
|
||||
node_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
|
||||
edge_fields: Schema.Array(Schema.String),
|
||||
edge_types: Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
|
||||
}),
|
||||
}),
|
||||
nodes: Schema.Array(number),
|
||||
edges: Schema.Array(number),
|
||||
strings: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
export function analyzeTrace(value: unknown, limit = 100) {
|
||||
const decoded = Schema.decodeUnknownOption(Trace)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a Chromium performance trace. Use a fileID returned by browser.trace.stop for this tab; CPU profiles and heap snapshots use their own analysis tools.",
|
||||
)
|
||||
const trace = decoded.value
|
||||
const events = new Map<string, { name: string; count: number; totalMs: number; maxMs: number }>()
|
||||
const longTasks: number[] = []
|
||||
trace.traceEvents.forEach((event) => {
|
||||
if (event.ph !== "X" || !event.name || event.dur === undefined) return
|
||||
const duration = event.dur / 1000
|
||||
const item = events.get(event.name) ?? { name: event.name.slice(0, 2_048), count: 0, totalMs: 0, maxMs: 0 }
|
||||
item.count++
|
||||
item.totalMs += duration
|
||||
item.maxMs = Math.max(item.maxMs, duration)
|
||||
events.set(event.name, item)
|
||||
if ((event.name === "RunTask" || event.name === "ThreadControllerImpl::RunTask") && duration > 50)
|
||||
longTasks.push(duration)
|
||||
})
|
||||
return {
|
||||
metrics: [
|
||||
{ name: "recordedEvents", value: trace.traceEvents.length, unit: "count" },
|
||||
{ name: "longTasks", value: longTasks.length, unit: "count" },
|
||||
{ name: "longTaskBlocking", value: longTasks.reduce((sum, duration) => sum + duration - 50, 0), unit: "ms" },
|
||||
],
|
||||
events: Array.from(events.values())
|
||||
.sort((a, b) => b.totalMs - a.totalMs)
|
||||
.slice(0, limit),
|
||||
insights: [
|
||||
"Event durations can overlap and must not be summed into total page time.",
|
||||
longTasks.length
|
||||
? `${longTasks.length} recorded tasks exceeded 50 ms; inspect their stacks in the exported trace.`
|
||||
: "No tasks over 50 ms were observed in the retained trace. This is not proof that the page has no long tasks.",
|
||||
"This renderer-process trace does not calculate LCP, INP, CLS, or Lighthouse performance scores.",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function analyzeCpu(value: unknown, limit = 100) {
|
||||
const decoded = Schema.decodeUnknownOption(Cpu)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a CPU profile. Use a fileID returned by browser.cpu.stop for this tab, not a trace or heap snapshot.",
|
||||
)
|
||||
const profile = decoded.value
|
||||
const times = new Map<number, number>()
|
||||
profile.samples?.forEach((id, index) =>
|
||||
times.set(id, (times.get(id) ?? 0) + (profile.timeDeltas?.[index] ?? 0) / 1000),
|
||||
)
|
||||
return {
|
||||
durationMs: Math.max(0, (profile.endTime - profile.startTime) / 1000),
|
||||
functions: profile.nodes
|
||||
.map((node) => ({
|
||||
name: node.callFrame.functionName.slice(0, 2_048),
|
||||
url: node.callFrame.url.slice(0, 100_000),
|
||||
line: Math.max(0, node.callFrame.lineNumber + 1),
|
||||
selfMs: times.get(node.id) ?? 0,
|
||||
}))
|
||||
.filter((node) => node.selfMs > 0)
|
||||
.sort((a, b) => b.selfMs - a.selfMs)
|
||||
.slice(0, limit),
|
||||
}
|
||||
}
|
||||
|
||||
const malformedHeap = () =>
|
||||
new Error(
|
||||
"Heap snapshot layout is unsupported or incomplete. Use a complete capture from browser.heap.snapshot; if this tool produced it, report a parser/Chromium compatibility issue instead of repeatedly capturing the same heap.",
|
||||
)
|
||||
|
||||
export function parseHeap(value: unknown) {
|
||||
const decoded = Schema.decodeUnknownOption(Heap)(value)
|
||||
if (decoded._tag === "None")
|
||||
throw new Error(
|
||||
"Selected file is not a V8 heap snapshot. Use a fileID returned by browser.heap.snapshot for this tab, not a trace or CPU profile.",
|
||||
)
|
||||
const heap = decoded.value
|
||||
const fields = heap.snapshot.meta.node_fields
|
||||
const edgeFields = heap.snapshot.meta.edge_fields
|
||||
const width = fields.length
|
||||
const edgeWidth = edgeFields.length
|
||||
const indexes = {
|
||||
type: fields.indexOf("type"),
|
||||
name: fields.indexOf("name"),
|
||||
id: fields.indexOf("id"),
|
||||
size: fields.indexOf("self_size"),
|
||||
count: fields.indexOf("edge_count"),
|
||||
edgeType: edgeFields.indexOf("type"),
|
||||
edgeName: edgeFields.indexOf("name_or_index"),
|
||||
to: edgeFields.indexOf("to_node"),
|
||||
}
|
||||
if (
|
||||
!width ||
|
||||
!edgeWidth ||
|
||||
Object.values(indexes).some((index) => index < 0) ||
|
||||
heap.nodes.length % width ||
|
||||
heap.edges.length % edgeWidth
|
||||
)
|
||||
throw malformedHeap()
|
||||
const types = heap.snapshot.meta.node_types[indexes.type]
|
||||
const edgeTypes = heap.snapshot.meta.edge_types[indexes.edgeType]
|
||||
if (!Array.isArray(types) || !Array.isArray(edgeTypes))
|
||||
throw new Error(
|
||||
"Heap snapshot type tables are unsupported. Use a complete capture from browser.heap.snapshot and report the compatibility issue if it persists.",
|
||||
)
|
||||
const node = (offset: number) => ({
|
||||
id: heap.nodes[offset + indexes.id],
|
||||
name: (heap.strings[heap.nodes[offset + indexes.name]] ?? "").slice(0, 100_000),
|
||||
type: types[heap.nodes[offset + indexes.type]] ?? "unknown",
|
||||
selfBytes: heap.nodes[offset + indexes.size],
|
||||
edgeCount: heap.nodes[offset + indexes.count],
|
||||
})
|
||||
const classes = new Map<string, { name: string; count: number; bytes: number }>()
|
||||
let selfBytes = 0
|
||||
let edgeTotal = 0
|
||||
for (let offset = 0; offset < heap.nodes.length; offset += width) {
|
||||
const item = node(offset)
|
||||
if (!Number.isSafeInteger(item.edgeCount) || item.edgeCount < 0) throw malformedHeap()
|
||||
edgeTotal += item.edgeCount
|
||||
const name = item.name.slice(0, 2_048)
|
||||
const entry = classes.get(name) ?? { name, count: 0, bytes: 0 }
|
||||
entry.count++
|
||||
entry.bytes += item.selfBytes
|
||||
selfBytes += item.selfBytes
|
||||
classes.set(name, entry)
|
||||
}
|
||||
// Edge counts drive the traversal below; a downloaded file can claim trillions of edges it does not carry.
|
||||
if (edgeTotal * edgeWidth !== heap.edges.length) throw malformedHeap()
|
||||
for (let offset = indexes.to; offset < heap.edges.length; offset += edgeWidth) {
|
||||
const to = heap.edges[offset]
|
||||
if (!Number.isSafeInteger(to) || to < 0 || to >= heap.nodes.length || to % width) throw malformedHeap()
|
||||
}
|
||||
return {
|
||||
summary(limit = 100) {
|
||||
return {
|
||||
nodes: heap.nodes.length / width,
|
||||
edges: heap.edges.length / edgeWidth,
|
||||
selfBytes,
|
||||
classes: Array.from(classes.values())
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, limit),
|
||||
}
|
||||
},
|
||||
classes,
|
||||
query(name = "", limit = 100) {
|
||||
const found: ReturnType<typeof node>[] = []
|
||||
for (let offset = 0; offset < heap.nodes.length; offset += width) {
|
||||
const item = node(offset)
|
||||
if (item.name.toLowerCase().includes(name.toLowerCase())) found.push(item)
|
||||
}
|
||||
return { nodes: found.sort((a, b) => b.selfBytes - a.selfBytes).slice(0, limit), truncated: found.length > limit }
|
||||
},
|
||||
object(id: number, limit = 100) {
|
||||
const target = heap.nodes.findIndex((value, index) => index % width === indexes.id && value === id) - indexes.id
|
||||
if (target < 0)
|
||||
throw new Error(
|
||||
"Object ID was not found in this heap snapshot. Call browser.heap.query with the same tabID and fileID, then copy an exact returned object id. Object IDs cannot be reused across snapshots.",
|
||||
)
|
||||
const references: { name: string; node: ReturnType<typeof node> }[] = []
|
||||
const retainers: { name: string; node: ReturnType<typeof node> }[] = []
|
||||
let edgeOffset = 0
|
||||
let truncated = false
|
||||
for (let offset = 0; offset < heap.nodes.length; offset += width) {
|
||||
const count = heap.nodes[offset + indexes.count]
|
||||
for (let index = 0; index < count; index++, edgeOffset += edgeWidth) {
|
||||
const to = heap.edges[edgeOffset + indexes.to]
|
||||
if (offset !== target && to !== target) continue
|
||||
const type = edgeTypes[heap.edges[edgeOffset + indexes.edgeType]]
|
||||
const raw = heap.edges[edgeOffset + indexes.edgeName]
|
||||
const name = (type === "element" || type === "hidden" ? String(raw) : (heap.strings[raw] ?? "")).slice(
|
||||
0,
|
||||
100_000,
|
||||
)
|
||||
if (offset === target) {
|
||||
if (references.length < limit) references.push({ name, node: node(to) })
|
||||
else truncated = true
|
||||
}
|
||||
// A weak edge (WeakRef, WeakMap key) does not keep the target alive, so it is not a retainer.
|
||||
if (to === target && type !== "weak") {
|
||||
if (retainers.length < limit) retainers.push({ name, node: node(offset) })
|
||||
else truncated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { node: node(target), references, retainers, truncated }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { WebContents } from "electron"
|
||||
import type { ProtocolMapping } from "devtools-protocol/types/protocol-mapping.js"
|
||||
import { protocolError } from "./errors"
|
||||
|
||||
export type Cdp = ReturnType<typeof createCdp>
|
||||
|
||||
export function createCdp(contents: WebContents) {
|
||||
const listeners = new Map<string, Set<(params: unknown, sessionID?: string) => void>>()
|
||||
const sessions = new Set([""])
|
||||
const receive = (_event: Electron.Event, name: string, params: unknown, sessionID?: string) => {
|
||||
if (!sessions.has(sessionID ?? "")) return
|
||||
if (name === "Target.attachedToTarget") {
|
||||
const event = params as ProtocolMapping.Events["Target.attachedToTarget"][0]
|
||||
if (event.targetInfo.type === "iframe") sessions.add(event.sessionId)
|
||||
}
|
||||
if (name === "Target.detachedFromTarget")
|
||||
sessions.delete((params as ProtocolMapping.Events["Target.detachedFromTarget"][0]).sessionId)
|
||||
listeners.get(name)?.forEach((callback) => callback(params, sessionID || undefined))
|
||||
}
|
||||
contents.debugger.on("message", receive)
|
||||
return {
|
||||
async send<Method extends keyof ProtocolMapping.Commands>(
|
||||
method: Method,
|
||||
params: object = {},
|
||||
sessionID?: string,
|
||||
): Promise<ProtocolMapping.Commands[Method]["returnType"]> {
|
||||
if (contents.isDestroyed())
|
||||
throw new Error(
|
||||
"Browser tab was closed. Call browser.tabs.list({}) and choose an existing tabID, or browser.tabs.open({}) if no tabs remain.",
|
||||
)
|
||||
// attach can throw synchronously; sendCommand can reject asynchronously.
|
||||
try {
|
||||
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
|
||||
return await contents.debugger.sendCommand(method, params, sessionID)
|
||||
} catch (error) {
|
||||
throw protocolError(method, error)
|
||||
}
|
||||
},
|
||||
on<Method extends keyof ProtocolMapping.Events>(
|
||||
method: Method,
|
||||
callback: (params: ProtocolMapping.Events[Method][0], sessionID?: string) => void,
|
||||
) {
|
||||
const handler = (params: unknown, sessionID?: string) =>
|
||||
callback(params as ProtocolMapping.Events[Method][0], sessionID)
|
||||
const handlers = listeners.get(method) ?? new Set()
|
||||
handlers.add(handler)
|
||||
listeners.set(method, handlers)
|
||||
return () => {
|
||||
handlers.delete(handler)
|
||||
if (!handlers.size) listeners.delete(method)
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
contents.debugger.off("message", receive)
|
||||
listeners.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function abortError(signal: AbortSignal) {
|
||||
if (signal.aborted)
|
||||
throw new Error(
|
||||
"Browser operation was cancelled. Inspect the tab before deciding to repeat an action; cancellation does not undo changes already made.",
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitFor(check: () => boolean | Promise<boolean>, signal: AbortSignal, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const timeout = () => new Error(`Condition was not met within ${timeoutMs} ms.`)
|
||||
// A busy renderer can hold one check past the deadline, so each check races the remaining time.
|
||||
while (true) {
|
||||
abortError(signal)
|
||||
const remaining = deadline - Date.now()
|
||||
if (remaining <= 0) throw timeout()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const expired = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(timeout()), remaining)
|
||||
})
|
||||
if (await Promise.race([check(), expired]).finally(() => clearTimeout(timer))) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { nativeImage } from "electron"
|
||||
|
||||
// Native browser surfaces ignore a parent View's clip path. Cover only the
|
||||
// pixels outside the bottom arcs; never resize or style the page itself.
|
||||
export function createCornerImages(color: readonly [number, number, number, number], radius: number, scale: number) {
|
||||
const size = Math.max(1, Math.round(radius * scale))
|
||||
return [false, true].map((right) => {
|
||||
const pixels = Buffer.alloc(size * size * 4)
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const distance = Math.hypot(right ? x + 0.5 : size - x - 0.5, y + 0.5)
|
||||
const alpha = Math.round(color[3] * Math.max(0, Math.min(1, distance - size + 0.5)))
|
||||
const offset = (y * size + x) * 4
|
||||
// NativeImage bitmaps use premultiplied BGRA on supported desktop platforms.
|
||||
pixels[offset] = Math.round((color[2] * alpha) / 255)
|
||||
pixels[offset + 1] = Math.round((color[1] * alpha) / 255)
|
||||
pixels[offset + 2] = Math.round((color[0] * alpha) / 255)
|
||||
pixels[offset + 3] = alpha
|
||||
}
|
||||
}
|
||||
return nativeImage.createFromBitmap(pixels, { width: size, height: size, scaleFactor: scale })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import type { Protocol } from "devtools-protocol"
|
||||
import type { Cdp } from "./cdp"
|
||||
|
||||
type Request = {
|
||||
info: Browser.NetworkRequest
|
||||
nativeID: string
|
||||
sessionID?: string
|
||||
/** Monotonic start; unknown for a WebSocket until its handshake is sent. */
|
||||
started?: number
|
||||
request: Pick<Protocol.Network.Request, "headers" | "postData" | "hasPostData">
|
||||
response?: Pick<Protocol.Network.Response, "headers" | "mimeType">
|
||||
headersTruncated: boolean
|
||||
postDataTruncated: boolean
|
||||
redirected?: boolean
|
||||
/** ExtraInfo arrives once per redirect hop, in order; these mark which hops consumed theirs. */
|
||||
wire: { request: boolean; response: boolean }
|
||||
}
|
||||
type Wire = { headers: Protocol.Network.Headers; statusCode: number }
|
||||
const levels = ["debug", "info", "warning", "error"] as const
|
||||
// Values the model must not read; the header name still shows it was sent.
|
||||
const redacted = new Set(["cookie", "set-cookie", "authorization", "proxy-authorization"])
|
||||
|
||||
export function createDiagnostics(cdp: Cdp) {
|
||||
const messages: Browser.ConsoleEntry[] = []
|
||||
const requests = new Map<string, Request>()
|
||||
// Chromium reuses one request ID across a redirect chain; every hop is retained in order.
|
||||
const hops = new Map<string, Request[]>()
|
||||
const latest = (key: string) => hops.get(key)?.at(-1)
|
||||
// Network-stack headers and wire status arrive as ExtraInfo events, in either order relative to
|
||||
// the renderer-side events; stash whichever comes first.
|
||||
const extra = new Map<string, { request?: Protocol.Network.Headers; response?: Wire }>()
|
||||
const scope = crypto.randomUUID()
|
||||
let sequence = 0
|
||||
let droppedMessages = 0
|
||||
let droppedRequests = 0
|
||||
const add = (
|
||||
level: (typeof levels)[number],
|
||||
text: string,
|
||||
timestampMs: number,
|
||||
source?: Browser.ConsoleEntry["source"],
|
||||
) => {
|
||||
messages.push({
|
||||
id: `${scope}:${++sequence}`,
|
||||
timestampMs,
|
||||
level,
|
||||
text: text.slice(0, 2_000),
|
||||
textTruncated: text.length > 2_000,
|
||||
...(source ? { source } : {}),
|
||||
})
|
||||
if (messages.length > 500) {
|
||||
messages.shift()
|
||||
droppedMessages++
|
||||
}
|
||||
}
|
||||
cdp.on("Runtime.consoleAPICalled", (event) => {
|
||||
const source = event.stackTrace?.callFrames[0]
|
||||
add(
|
||||
event.type === "error" || event.type === "assert"
|
||||
? "error"
|
||||
: event.type === "warning"
|
||||
? "warning"
|
||||
: event.type === "debug"
|
||||
? "debug"
|
||||
: "info",
|
||||
event.args
|
||||
.map((arg) =>
|
||||
typeof arg.value === "string"
|
||||
? arg.value
|
||||
: arg.value !== undefined
|
||||
? JSON.stringify(arg.value)
|
||||
: (arg.description ?? arg.unserializableValue ?? arg.type),
|
||||
)
|
||||
.join(" "),
|
||||
event.timestamp,
|
||||
source
|
||||
? { url: source.url.slice(0, Browser.MAX_TEXT), line: source.lineNumber + 1, column: source.columnNumber + 1 }
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
// Chromium's own messages (CSP refusals, mixed content, failed resource loads, deprecations)
|
||||
// are Log entries, not Runtime console calls.
|
||||
cdp.on("Log.entryAdded", (event) => {
|
||||
const entry = event.entry
|
||||
add(
|
||||
entry.level === "verbose" ? "debug" : entry.level,
|
||||
entry.text,
|
||||
entry.timestamp,
|
||||
entry.url
|
||||
? { url: entry.url.slice(0, Browser.MAX_TEXT), line: (entry.lineNumber ?? 0) + 1, column: 1 }
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
cdp.on("Runtime.exceptionThrown", (event) => {
|
||||
const error = event.exceptionDetails
|
||||
add(
|
||||
"error",
|
||||
error.exception?.description ?? error.text,
|
||||
event.timestamp,
|
||||
error.url ? { url: error.url, line: error.lineNumber + 1, column: error.columnNumber + 1 } : undefined,
|
||||
)
|
||||
})
|
||||
const begin = (
|
||||
key: string,
|
||||
sessionID: string | undefined,
|
||||
nativeID: string,
|
||||
started: number | undefined,
|
||||
wallTime: number,
|
||||
info: Pick<Browser.NetworkRequest, "url" | "method" | "resourceType">,
|
||||
request: Request["request"],
|
||||
) => {
|
||||
const headers = trimHeaders(request.headers)
|
||||
const entry: Request = {
|
||||
nativeID,
|
||||
sessionID,
|
||||
started,
|
||||
request: { ...request, headers: headers.headers, postData: request.postData?.slice(0, 20_000) },
|
||||
headersTruncated: headers.truncated,
|
||||
postDataTruncated: (request.postData?.length ?? 0) > 20_000,
|
||||
wire: { request: false, response: false },
|
||||
info: {
|
||||
id: `${scope}:${++sequence}`,
|
||||
...info,
|
||||
url: info.url.slice(0, 16_384),
|
||||
timestampMs: wallTime * 1000,
|
||||
state: "pending",
|
||||
},
|
||||
}
|
||||
requests.set(entry.info.id, entry)
|
||||
hops.set(key, [...(hops.get(key) ?? []), entry])
|
||||
// A stash can only belong to this hop: earlier hops would have consumed it on arrival.
|
||||
const stashed = extra.get(key)
|
||||
if (stashed?.request) requestHeaders(entry, stashed.request)
|
||||
if (stashed?.response) responseInfo(entry, stashed.response)
|
||||
extra.delete(key)
|
||||
if (requests.size > 500) {
|
||||
const first = requests.values().next().value
|
||||
if (first) {
|
||||
requests.delete(first.info.id)
|
||||
const firstKey = `${first.sessionID ?? ""}:${first.nativeID}`
|
||||
const rest = hops.get(firstKey)?.filter((hop) => hop !== first) ?? []
|
||||
if (rest.length) hops.set(firstKey, rest)
|
||||
if (!rest.length) hops.delete(firstKey)
|
||||
}
|
||||
droppedRequests++
|
||||
}
|
||||
return entry
|
||||
}
|
||||
const requestHeaders = (request: Request, headers: Protocol.Network.Headers) => {
|
||||
const trimmed = trimHeaders({ ...request.request.headers, ...headers })
|
||||
request.request = { ...request.request, headers: trimmed.headers }
|
||||
request.headersTruncated ||= trimmed.truncated
|
||||
request.wire.request = true
|
||||
}
|
||||
const responseInfo = (request: Request, wire: Wire) => {
|
||||
const trimmed = trimHeaders({ ...request.response?.headers, ...wire.headers })
|
||||
request.response = { mimeType: request.response?.mimeType ?? "", headers: trimmed.headers }
|
||||
request.headersTruncated ||= trimmed.truncated
|
||||
request.info = { ...request.info, statusCode: wire.statusCode }
|
||||
request.wire.response = true
|
||||
}
|
||||
const finish = (key: string, timestamp: number, failure?: string) => {
|
||||
const request = latest(key)
|
||||
if (!request) return
|
||||
const durationMs = request.started === undefined ? 0 : Math.max(0, (timestamp - request.started) * 1000)
|
||||
request.info =
|
||||
failure === undefined
|
||||
? { ...request.info, state: "completed", durationMs }
|
||||
: { ...request.info, state: "failed", failure: failure.slice(0, 2_048), durationMs }
|
||||
}
|
||||
cdp.on("Network.requestWillBeSent", (event, sessionID) => {
|
||||
const key = `${sessionID ?? ""}:${event.requestId}`
|
||||
const previous = latest(key)
|
||||
if (previous && event.redirectResponse) {
|
||||
// Wire headers for this hop may already be merged in; the renderer copy lacks set-cookie.
|
||||
const response = trimHeaders({ ...event.redirectResponse.headers, ...previous.response?.headers })
|
||||
previous.response = { mimeType: event.redirectResponse.mimeType, headers: response.headers }
|
||||
previous.headersTruncated ||= response.truncated
|
||||
previous.redirected = true
|
||||
// A cached redirect never gets ExtraInfo; the next hop's must not be attributed to it.
|
||||
if (!event.redirectHasExtraInfo) previous.wire = { request: true, response: true }
|
||||
previous.info = {
|
||||
...previous.info,
|
||||
state: "completed",
|
||||
statusCode: previous.info.statusCode ?? event.redirectResponse.status,
|
||||
durationMs: Math.max(0, (event.timestamp - (previous.started ?? event.timestamp)) * 1000),
|
||||
}
|
||||
}
|
||||
begin(
|
||||
key,
|
||||
sessionID,
|
||||
event.requestId,
|
||||
event.timestamp,
|
||||
event.wallTime,
|
||||
{
|
||||
url: event.request.url,
|
||||
method: event.request.method,
|
||||
resourceType: resourceType((event.type ?? "other").toLowerCase()),
|
||||
},
|
||||
{ headers: event.request.headers, hasPostData: event.request.hasPostData, postData: event.request.postData },
|
||||
)
|
||||
})
|
||||
// ExtraInfo for a redirect hop can land after the renderer already started the next hop, so it
|
||||
// goes to the earliest hop that has not consumed its own rather than to the latest.
|
||||
cdp.on("Network.requestWillBeSentExtraInfo", (event, sessionID) => {
|
||||
const key = `${sessionID ?? ""}:${event.requestId}`
|
||||
const request = hops.get(key)?.find((hop) => !hop.wire.request)
|
||||
if (request) return requestHeaders(request, event.headers)
|
||||
extra.set(key, { ...extra.get(key), request: event.headers })
|
||||
})
|
||||
cdp.on("Network.responseReceived", (event, sessionID) => {
|
||||
const request = latest(`${sessionID ?? ""}:${event.requestId}`)
|
||||
if (!request) return
|
||||
const headers = trimHeaders({ ...event.response.headers, ...request.response?.headers })
|
||||
request.response = { mimeType: event.response.mimeType, headers: headers.headers }
|
||||
request.headersTruncated ||= headers.truncated
|
||||
// ExtraInfo already carries the wire status when it arrived first; the renderer may report 200 for a 304.
|
||||
request.info = { ...request.info, statusCode: request.info.statusCode ?? event.response.status }
|
||||
if (!event.hasExtraInfo) request.wire = { request: true, response: true }
|
||||
})
|
||||
cdp.on("Network.responseReceivedExtraInfo", (event, sessionID) => {
|
||||
const key = `${sessionID ?? ""}:${event.requestId}`
|
||||
const request = hops.get(key)?.find((hop) => !hop.wire.response)
|
||||
if (request) return responseInfo(request, event)
|
||||
extra.set(key, { ...extra.get(key), response: event })
|
||||
})
|
||||
// WebSockets never emit requestWillBeSent; their handshake is the whole request lifecycle.
|
||||
cdp.on("Network.webSocketCreated", (event, sessionID) => {
|
||||
begin(
|
||||
`${sessionID ?? ""}:${event.requestId}`,
|
||||
sessionID,
|
||||
event.requestId,
|
||||
undefined,
|
||||
Date.now() / 1000,
|
||||
{ url: event.url, method: "GET", resourceType: "websocket" },
|
||||
{ headers: {}, hasPostData: false },
|
||||
)
|
||||
})
|
||||
cdp.on("Network.webSocketWillSendHandshakeRequest", (event, sessionID) => {
|
||||
const request = latest(`${sessionID ?? ""}:${event.requestId}`)
|
||||
if (!request) return
|
||||
request.started = event.timestamp
|
||||
request.info = { ...request.info, timestampMs: event.wallTime * 1000 }
|
||||
requestHeaders(request, event.request.headers)
|
||||
})
|
||||
cdp.on("Network.webSocketHandshakeResponseReceived", (event, sessionID) => {
|
||||
const key = `${sessionID ?? ""}:${event.requestId}`
|
||||
const request = latest(key)
|
||||
if (!request) return
|
||||
responseInfo(request, { headers: event.response.headers, statusCode: event.response.status })
|
||||
finish(key, event.timestamp)
|
||||
})
|
||||
cdp.on("Network.webSocketFrameError", (event, sessionID) =>
|
||||
finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp, event.errorMessage),
|
||||
)
|
||||
cdp.on("Network.webSocketClosed", (event, sessionID) => {
|
||||
const key = `${sessionID ?? ""}:${event.requestId}`
|
||||
if (latest(key)?.info.state === "pending") finish(key, event.timestamp, "Closed before the handshake completed")
|
||||
})
|
||||
cdp.on("Network.loadingFinished", (event, sessionID) =>
|
||||
finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp),
|
||||
)
|
||||
cdp.on("Network.loadingFailed", (event, sessionID) =>
|
||||
finish(`${sessionID ?? ""}:${event.requestId}`, event.timestamp, event.errorText),
|
||||
)
|
||||
return {
|
||||
clear() {
|
||||
messages.length = 0
|
||||
requests.clear()
|
||||
hops.clear()
|
||||
extra.clear()
|
||||
droppedMessages = 0
|
||||
droppedRequests = 0
|
||||
},
|
||||
async enable(sessionID?: string) {
|
||||
await cdp.send("Runtime.enable", {}, sessionID)
|
||||
await cdp.send("Log.enable", {}, sessionID)
|
||||
await cdp.send(
|
||||
"Network.enable",
|
||||
{ maxTotalBufferSize: 5 * 1024 * 1024, maxResourceBufferSize: 1024 * 1024, maxPostDataSize: 20_000 },
|
||||
sessionID,
|
||||
)
|
||||
},
|
||||
console(input: Extract<Browser.Action, { type: "console" }>) {
|
||||
const matching = messages.filter((entry) => levels.indexOf(entry.level) >= levels.indexOf(input.level ?? "info"))
|
||||
const result = bounded(matching, input.limit ?? 100)
|
||||
return { messages: result, truncated: matching.length > result.length, dropped: droppedMessages }
|
||||
},
|
||||
list(input: Extract<Browser.Action, { type: "network.list" }>) {
|
||||
const matching = Array.from(requests.values())
|
||||
.map((request) => request.info)
|
||||
.filter(
|
||||
(request) =>
|
||||
(!input.urlContains || request.url.includes(input.urlContains)) &&
|
||||
(!input.resourceType || input.resourceType === request.resourceType),
|
||||
)
|
||||
const result = bounded(matching, input.limit ?? 100)
|
||||
return { requests: result, truncated: matching.length > result.length, dropped: droppedRequests }
|
||||
},
|
||||
info(id: string) {
|
||||
const request = requests.get(id)
|
||||
if (!request)
|
||||
throw new Error(
|
||||
"Request is no longer retained. Call browser.network.list({tabID}) and use a current request id. Do not reload or resend it just to inspect it.",
|
||||
)
|
||||
return request.info
|
||||
},
|
||||
async get(input: Extract<Browser.Action, { type: "network.get" }>) {
|
||||
const request = requests.get(input.id)
|
||||
if (!request)
|
||||
throw new Error(
|
||||
"Request ID is no longer retained in this tab or belongs to another tab. Call browser.network.list({tabID}) and copy a current id into browser.network.get with the same tabID. Do not reload or resend a request just to inspect it.",
|
||||
)
|
||||
const max = input.maxBodyChars ?? 20_000
|
||||
const text = (value: string): Browser.Body => ({
|
||||
state: "text",
|
||||
text: value.slice(0, max),
|
||||
truncated: value.length > max,
|
||||
})
|
||||
const responseBody = async (): Promise<Browser.Body> => {
|
||||
if (!input.includeBody) return { state: "notRequested" }
|
||||
if (request.info.state === "pending") return { state: "pending" }
|
||||
if (request.redirected || !request.response || request.info.resourceType === "websocket")
|
||||
return { state: "unavailable", reason: "notCaptured" }
|
||||
if (
|
||||
!/^(text\/|application\/(json|.*\+json|javascript|xml|.*\+xml|x-www-form-urlencoded))/i.test(
|
||||
request.response.mimeType,
|
||||
)
|
||||
)
|
||||
return { state: "unavailable", reason: "binary" }
|
||||
const body = await cdp
|
||||
.send("Network.getResponseBody", { requestId: request.nativeID }, request.sessionID)
|
||||
.catch(() => undefined)
|
||||
if (!body) return { state: "unavailable", reason: "backendUnavailable" }
|
||||
const value = body.base64Encoded ? Buffer.from(body.body, "base64").toString("utf8") : body.body
|
||||
return value.length ? text(value) : { state: "empty" }
|
||||
}
|
||||
const requestBody: Browser.Body = !input.includeBody
|
||||
? { state: "notRequested" }
|
||||
: request.request.postData !== undefined
|
||||
? {
|
||||
state: "text",
|
||||
text: request.request.postData.slice(0, max),
|
||||
truncated: request.postDataTruncated || request.request.postData.length > max,
|
||||
}
|
||||
: request.request.hasPostData
|
||||
? { state: "unavailable", reason: "notCaptured" }
|
||||
: { state: "empty" }
|
||||
return {
|
||||
request: request.info,
|
||||
requestHeaders: headerEntries(request.request.headers),
|
||||
responseHeaders: headerEntries(request.response?.headers ?? {}),
|
||||
headersTruncated: request.headersTruncated,
|
||||
requestBody,
|
||||
responseBody: await responseBody(),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resourceType(type: string): Browser.ResourceType {
|
||||
switch (type) {
|
||||
case "document":
|
||||
case "stylesheet":
|
||||
case "image":
|
||||
case "media":
|
||||
case "font":
|
||||
case "script":
|
||||
case "xhr":
|
||||
case "fetch":
|
||||
case "eventsource":
|
||||
case "websocket":
|
||||
case "manifest":
|
||||
return type
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
function trimHeaders(headers: Protocol.Network.Headers) {
|
||||
const entries = Object.entries(headers)
|
||||
const kept: [string, string][] = []
|
||||
let size = 0
|
||||
let truncated = entries.length > 100
|
||||
for (const [key, value] of entries.slice(0, 100)) {
|
||||
// Lower-case names so renderer and wire copies of one header merge instead of duplicating.
|
||||
const name = key.toLowerCase().slice(0, 2_048)
|
||||
const text = redacted.has(name) ? "<redacted>" : String(value)
|
||||
const remaining = Math.max(0, 16_000 - size - name.length)
|
||||
if (!remaining) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
const bounded = text.slice(0, Math.min(2_000, remaining))
|
||||
truncated ||= bounded.length < text.length || name.length < key.length
|
||||
kept.push([name, bounded])
|
||||
size += name.length + bounded.length
|
||||
}
|
||||
return { headers: Object.fromEntries(kept), truncated }
|
||||
}
|
||||
|
||||
function bounded<Item>(items: readonly Item[], limit: number) {
|
||||
const selected: Item[] = []
|
||||
let size = 0
|
||||
for (const item of items.slice(-limit).reverse()) {
|
||||
size += JSON.stringify(item).length
|
||||
if (size > Browser.MAX_TEXT) break
|
||||
selected.push(item)
|
||||
}
|
||||
return selected.reverse()
|
||||
}
|
||||
|
||||
function headerEntries(headers: Protocol.Network.Headers) {
|
||||
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
|
||||
export function protocolError(method: string, error: unknown) {
|
||||
const detail = message(error)
|
||||
const recovery =
|
||||
method === "DOM.setFileInputFiles" && /not.*file input/i.test(detail)
|
||||
? "Target is not a file input. Call browser.snapshot({tabID}) and choose an input[type=file] ref for browser.files.upload, or use browser.files.drop for a drop area."
|
||||
: /(?:node|object).*(?:not found|not exist)|(?:find|resolve).*(?:node|object)|detached/i.test(detail)
|
||||
? "The element may have detached. Call browser.snapshot({tabID}) and use a fresh ref from that tab."
|
||||
: /context.*(?:destroyed|not found)|find.*context|session.*not found/i.test(detail)
|
||||
? "The document or frame changed. Call browser.frames({tabID}) and browser.snapshot({tabID}); use current frame IDs and refs."
|
||||
: /wasn't found|method not found|not implemented|not allowed/i.test(detail)
|
||||
? "This Chromium target does not support or allow the operation. Check desktop/plugin compatibility and report it; do not retry unchanged or disable browser security."
|
||||
: "Inspect browser.tabs.list({}) and the target tab before deciding to retry; a partially completed action is not automatically safe to repeat."
|
||||
return new Error(`${recovery} Chromium command ${method} failed: ${detail}`, { cause: error })
|
||||
}
|
||||
|
||||
export function browserFailure(action: Browser.Action, error: unknown): Extract<Browser.Outcome, { type: "failure" }> {
|
||||
const detail = message(error, 1_700)
|
||||
const navigation = ["tabs.open", "navigate", "back", "forward", "reload"].includes(action.type)
|
||||
const network = navigation ? detail.match(/\bERR_[A-Z_]+\b/)?.[0] : undefined
|
||||
const hint =
|
||||
network === "ERR_CONNECTION_REFUSED"
|
||||
? "The browser could not connect to the site. Check its hostname/port on the connected server. localhost means that server, not the desktop."
|
||||
: network === "ERR_NAME_NOT_RESOLVED"
|
||||
? "The connected server could not resolve the hostname. Check the URL spelling and the server's DNS/network connection."
|
||||
: network?.startsWith("ERR_CERT_") || network?.startsWith("ERR_SSL_")
|
||||
? "The desktop rejected the site's TLS connection. Ask the user to fix the certificate or trust configuration; do not bypass certificate checks."
|
||||
: network === "ERR_ABORTED"
|
||||
? "Navigation was interrupted or became a download. Inspect browser.tabs.list({}) and browser.files.list({tabID}) before deciding to navigate again."
|
||||
: network
|
||||
? "The site failed to load through the connected server. Check its URL/network and the OC2 connection before retrying; inspect the current tab first."
|
||||
: action.type === "screenshot" && /UnknownVizError|capture.*(?:failed|unavailable)/i.test(detail)
|
||||
? "Chromium could not capture a rendered frame. Call browser.tabs.focus({tabID}) and keep the desktop window visible. If it is already visible, report the capture failure instead of repeating it unchanged."
|
||||
: undefined
|
||||
return {
|
||||
type: "failure",
|
||||
code: network ? "navigation_failed" : "operation_failed",
|
||||
message: `browser.${action.type} failed. ${hint ? `${hint} Details: ${detail.slice(0, 400)}` : detail}`.slice(
|
||||
0,
|
||||
2_048,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function message(error: unknown, limit = 400) {
|
||||
return (error instanceof Error ? error.message : String(error)).slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { mkdir, readFile, stat, writeFile, rm } from "node:fs/promises"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
export type BrowserFiles = ReturnType<typeof createBrowserFiles>
|
||||
|
||||
export function createBrowserFiles(source: () => readonly string[]) {
|
||||
const directory = path.join(tmpdir(), `opencode-browser-client-${crypto.randomUUID()}`)
|
||||
const files = new Map<
|
||||
Browser.FileID,
|
||||
{
|
||||
id: Browser.FileID
|
||||
name: string
|
||||
mime: string
|
||||
bytes: number
|
||||
state: "pending" | "completed" | "failed"
|
||||
path: string
|
||||
resources: readonly string[]
|
||||
}
|
||||
>()
|
||||
// Captures, uploads, and heap snapshots sit in the shared temp directory; keep them owner-only.
|
||||
const ready = mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
return {
|
||||
directory,
|
||||
ready,
|
||||
list: () => Array.from(files.values()).map(({ path: _path, resources: _resources, ...file }) => file),
|
||||
add(name: string, mime: string, resources = source()) {
|
||||
const id = Browser.FileID.make(`file_${crypto.randomUUID()}`)
|
||||
const target = path.join(directory, id)
|
||||
// setSavePath must run during Electron's synchronous will-download callback.
|
||||
mkdirSync(target, { recursive: true, mode: 0o700 })
|
||||
const file = {
|
||||
id,
|
||||
name: name.slice(0, 2_048),
|
||||
mime,
|
||||
bytes: 0,
|
||||
state: "pending" as "pending" | "completed" | "failed",
|
||||
// A page receives this basename as the uploaded File.name, so spaces and non-ASCII stay;
|
||||
// only characters no supported filesystem accepts are replaced. Each file has its own
|
||||
// directory, so names never collide.
|
||||
path: path.join(
|
||||
target,
|
||||
name
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
||||
.replace(/[. ]+$/, "")
|
||||
.slice(-120) || "file",
|
||||
),
|
||||
resources: [...new Set(resources)].sort(),
|
||||
}
|
||||
files.set(id, file)
|
||||
return file
|
||||
},
|
||||
async save(name: string, mime: string, data: Uint8Array, resources = source()) {
|
||||
if (data.byteLength > Browser.MAX_FILE_BYTES)
|
||||
throw new Error(
|
||||
"Capture exceeds the 5 MiB transfer limit. Reduce screenshot maxWidth/quality or trace duration; for a heap snapshot, use a smaller page/test case. Do not retry an identical capture.",
|
||||
)
|
||||
await ready
|
||||
const file = this.add(name, mime, resources)
|
||||
await writeFile(file.path, data, { mode: 0o600 }).catch((error: unknown) => {
|
||||
file.state = "failed"
|
||||
throw new Error(
|
||||
"Cannot write the capture on the desktop. Ask the user to check desktop temporary-directory access and free space before retrying.",
|
||||
{ cause: error },
|
||||
)
|
||||
})
|
||||
file.bytes = data.byteLength
|
||||
file.state = "completed"
|
||||
return file.id
|
||||
},
|
||||
get(id: Browser.FileID) {
|
||||
const file = files.get(id)
|
||||
if (!file)
|
||||
throw new Error(
|
||||
"File ID is not retained in this tab. Call browser.files.list({tabID}) and use an exact returned fileID from the same tab, not a server path or request ID.",
|
||||
)
|
||||
if (file.state === "pending")
|
||||
throw new Error(
|
||||
"File is still being downloaded or captured. Check browser.files.list({tabID}) again and wait for state completed; do not start a duplicate download.",
|
||||
)
|
||||
if (file.state === "failed")
|
||||
throw new Error(
|
||||
"The download or capture failed, so this file cannot be read. Inspect browser.console and browser.network.list for the cause before deciding to start it again.",
|
||||
)
|
||||
return file
|
||||
},
|
||||
async transfer(id: Browser.FileID, authorized?: readonly string[]): Promise<Browser.File> {
|
||||
const file = this.get(id)
|
||||
if (authorized && file.resources.some((url) => !authorized.includes(url)))
|
||||
throw new Error(
|
||||
"Capture source changed before export. Inspect the tab and request the file again to check its source permissions; no bytes were exported.",
|
||||
)
|
||||
if (
|
||||
(
|
||||
await stat(file.path).catch((error: unknown) => {
|
||||
throw unavailableFile(error)
|
||||
})
|
||||
).size > Browser.MAX_FILE_BYTES
|
||||
)
|
||||
throw new Error(
|
||||
"File exceeds the 5 MiB transfer limit. Choose a smaller completed file; repeating browser.files.get for this file will not help.",
|
||||
)
|
||||
const data = await readFile(file.path).catch((error: unknown) => {
|
||||
throw unavailableFile(error)
|
||||
})
|
||||
return { id, name: file.name, mime: file.mime, data: new Uint8Array(data) }
|
||||
},
|
||||
dispose: () => ready.then(() => rm(directory, { recursive: true, force: true })),
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableFile(error: unknown) {
|
||||
return new Error(
|
||||
"The retained file cannot be read on the desktop. Its temporary copy may have been removed. Use an already exported server-local path if available, or deliberately create a new capture.",
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { WebContents } from "electron"
|
||||
import type { Page } from "puppeteer-core"
|
||||
import { EventEmitter } from "node:events"
|
||||
import type { BrowserFiles } from "./files"
|
||||
import type { Cdp } from "./cdp"
|
||||
|
||||
export async function audit(contents: WebContents, files: BrowserFiles, cdp: Cdp, resources: readonly string[]) {
|
||||
const { snapshot, generateReport } = await import("lighthouse")
|
||||
const info = (await contents.debugger.sendCommand("Target.getTargetInfo")) as { targetInfo: { targetId: string } }
|
||||
const sessions = new Map<string, ReturnType<typeof session>>()
|
||||
function session(id: string) {
|
||||
const emitter = new EventEmitter()
|
||||
const value = Object.assign(emitter, {
|
||||
id: () => id,
|
||||
send: (method: string, params?: object) => cdp.send(method as Parameters<Cdp["send"]>[0], params, id),
|
||||
detach: async () => {
|
||||
sessions.delete(id)
|
||||
await contents.debugger.sendCommand("Target.detachFromTarget", { sessionId: id })
|
||||
},
|
||||
})
|
||||
sessions.set(id, value)
|
||||
return value
|
||||
}
|
||||
const event = (_event: Electron.Event, method: string, params: unknown, sessionID?: string) => {
|
||||
const owner = sessionID ? sessions.get(sessionID) : undefined
|
||||
if (!owner) return
|
||||
if (method === "Target.attachedToTarget") {
|
||||
const child = params as { sessionId: string }
|
||||
owner.emit("sessionattached", session(child.sessionId))
|
||||
}
|
||||
owner.emit(method, params)
|
||||
owner.emit("*", method, params)
|
||||
}
|
||||
contents.debugger.on("message", event)
|
||||
try {
|
||||
const attached = (await contents.debugger.sendCommand("Target.attachToTarget", {
|
||||
targetId: info.targetInfo.targetId,
|
||||
flatten: true,
|
||||
})) as { sessionId: string }
|
||||
const root = session(attached.sessionId)
|
||||
// Lighthouse's snapshot driver uses only url() and target().createCDPSession().
|
||||
// Adapt that narrow boundary to Electron instead of exposing a browser-wide
|
||||
// Puppeteer connection or emulating unsupported Browser/Target commands.
|
||||
const page = {
|
||||
url: () => contents.getURL(),
|
||||
target: () => ({ createCDPSession: async () => root }),
|
||||
} as unknown as Page
|
||||
// Our screenshot tool handles captures separately. Lighthouse's screenshot
|
||||
// gatherer resizes the viewport and waits for frames that hidden views may not paint.
|
||||
const result = await snapshot(page, {
|
||||
flags: {
|
||||
onlyCategories: ["accessibility", "seo", "best-practices"],
|
||||
formFactor: "desktop",
|
||||
screenEmulation: { disabled: true },
|
||||
throttlingMethod: "provided",
|
||||
disableStorageReset: true,
|
||||
disableFullPageScreenshot: true,
|
||||
},
|
||||
})
|
||||
if (!result)
|
||||
throw new Error(
|
||||
"Lighthouse did not produce a report. Check browser.console and browser.network.list for page failures and confirm the tab has loaded. Report an audit failure if the page is healthy; do not repeat the audit unchanged.",
|
||||
)
|
||||
return {
|
||||
scores: Object.values(result.lhr.categories).map((category) => ({
|
||||
id: category.id,
|
||||
title: category.title,
|
||||
score: category.score,
|
||||
})),
|
||||
failures: Object.values(result.lhr.audits)
|
||||
.filter((audit) => audit.score !== null && audit.score < 1)
|
||||
.map((audit) => ({ id: audit.id, title: audit.title, description: audit.description }))
|
||||
.slice(0, 100),
|
||||
files: await Promise.all([
|
||||
files.save("lighthouse.json", "application/json", Buffer.from(generateReport(result.lhr, "json")), [
|
||||
...resources,
|
||||
contents.getURL(),
|
||||
]),
|
||||
files.save("lighthouse.html", "text/html", Buffer.from(generateReport(result.lhr, "html")), [
|
||||
...resources,
|
||||
contents.getURL(),
|
||||
]),
|
||||
]),
|
||||
}
|
||||
} finally {
|
||||
contents.debugger.off("message", event)
|
||||
await Promise.all(Array.from(sessions.values(), (session) => session.detach().catch(() => undefined)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { session, type WebContents } from "electron"
|
||||
import type { RpcClient } from "@opencode-ai/client/effect/api"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { BrowserProxy } from "@opencode-ai/plugin-browser/proxy"
|
||||
import { Effect, Encoding } from "effect"
|
||||
|
||||
export type BrowserNetwork = Effect.Success<ReturnType<typeof createBrowserNetwork>>
|
||||
|
||||
export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* (input: {
|
||||
rpc: RpcClient<typeof Browser.Definition, unknown>
|
||||
attachment: { sessionID: Session.ID; connectionID: string }
|
||||
location: { directory: string; workspace?: string }
|
||||
partition: string
|
||||
}) {
|
||||
const options = { location: input.location }
|
||||
const proxy = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
BrowserProxy.make({
|
||||
open: (target, signal) =>
|
||||
Effect.runPromise(input.rpc["tunnel.open"]({ ...input.attachment, target }, options), { signal }),
|
||||
read: (tunnelID, signal) =>
|
||||
Effect.runPromise(input.rpc["tunnel.read"]({ ...input.attachment, tunnelID }, options), { signal }),
|
||||
write: (tunnelID, data, end, signal) =>
|
||||
Effect.runPromise(
|
||||
input.rpc["tunnel.write"](
|
||||
{ ...input.attachment, tunnelID, data: Encoding.encodeBase64(data), end },
|
||||
options,
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
close: (tunnelID) =>
|
||||
Effect.runPromise(
|
||||
input.rpc["tunnel.close"]({ ...input.attachment, tunnelID }, options).pipe(Effect.timeout("5 seconds")),
|
||||
),
|
||||
}),
|
||||
),
|
||||
(proxy) => Effect.promise(() => proxy.close()),
|
||||
)
|
||||
const partition = session.fromPartition(input.partition)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => partition.closeAllConnections()))
|
||||
// This is the browser's private partition, not the app/API connection. Never
|
||||
// bypass localhost: it must resolve on the machine running the OC2 server.
|
||||
yield* Effect.tryPromise(() =>
|
||||
partition.setProxy({ mode: "fixed_servers", proxyRules: proxy.url, proxyBypassRules: "<-loopback>" }),
|
||||
)
|
||||
yield* Effect.tryPromise(() => partition.closeAllConnections())
|
||||
return {
|
||||
attach(contents: WebContents) {
|
||||
const login = (
|
||||
event: Electron.Event,
|
||||
_details: Electron.AuthenticationResponseDetails,
|
||||
auth: Electron.AuthInfo,
|
||||
callback: (username?: string, password?: string) => void,
|
||||
) => {
|
||||
if (
|
||||
!auth.isProxy ||
|
||||
auth.scheme !== "basic" ||
|
||||
auth.host !== proxy.host ||
|
||||
auth.port !== proxy.port ||
|
||||
auth.realm !== "OpenCode Browser Proxy"
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
callback(proxy.credentials.username, proxy.credentials.password)
|
||||
}
|
||||
contents.on("login", login)
|
||||
contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp")
|
||||
return () => contents.off("login", login)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
// URL policy shared by the pane and page. Electron-free so it stays unit-testable under Bun.
|
||||
|
||||
export function destinationOrigin(input: string) {
|
||||
if (!URL.canParse(input)) return
|
||||
const url = new URL(input)
|
||||
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
|
||||
}
|
||||
|
||||
export function normalizeURL(input: string) {
|
||||
const value = input.trim() || "about:blank"
|
||||
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
|
||||
const url =
|
||||
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}`
|
||||
if (url !== "about:blank" && !destinationOrigin(url))
|
||||
throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported.")
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { WebContents } from "electron"
|
||||
import { Browser } from "@opencode-ai/plugin-browser/rpc"
|
||||
import { gzipSync, gunzipSync } from "node:zlib"
|
||||
import { Schema } from "effect"
|
||||
import type { Cdp } from "./cdp"
|
||||
import type { BrowserFiles } from "./files"
|
||||
import { analyzeCpu, analyzeTrace, parseHeap } from "./analysis"
|
||||
|
||||
let recording:
|
||||
| {
|
||||
owner: WebContents
|
||||
pid: number
|
||||
started: number
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
finish: () => Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }>
|
||||
}
|
||||
| undefined
|
||||
|
||||
export function createProfiling(contents: WebContents, cdp: Cdp, files: BrowserFiles, source: () => readonly string[]) {
|
||||
let trace: Promise<{ id: Browser.FileID; durationMs: number; incomplete: boolean }> | undefined
|
||||
let traceResources = new Set<string>()
|
||||
let traceID = ""
|
||||
let cpu:
|
||||
| {
|
||||
started: number
|
||||
id: string
|
||||
resources: Set<string>
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
result?: Promise<{ id: Browser.FileID; durationMs: number }>
|
||||
}
|
||||
| undefined
|
||||
let takingHeap = false
|
||||
cdp.on("Page.frameNavigated", ({ frame }) => {
|
||||
if (recording?.owner === contents) traceResources.add(frame.url)
|
||||
if (cpu && !cpu.result) cpu.resources.add(frame.url)
|
||||
})
|
||||
const json = async (id: Browser.FileID) => {
|
||||
const file = await files.transfer(id)
|
||||
try {
|
||||
const data = Buffer.from(file.data)
|
||||
return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
(file.name.endsWith(".gz") ? gunzipSync(data, { maxOutputLength: 128 * 1024 * 1024 }) : data).toString("utf8"),
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Selected file cannot be decoded as a JSON capture, or expands beyond the 128 MiB analysis limit. Call browser.files.list({tabID}) and choose the fileID from the matching trace, CPU, or heap capture, not a screenshot/download. Do not retry the same invalid file.",
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
const stopCpu = () => {
|
||||
if (!cpu)
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"No CPU profile has been started in this tab. Call browser.cpu.start({tabID}), perform the interaction to inspect, then browser.cpu.stop({tabID}).",
|
||||
),
|
||||
)
|
||||
if (cpu.result) return cpu.result
|
||||
const resources = cpu.resources
|
||||
clearTimeout(cpu.timer)
|
||||
cpu.result = cdp.send("Profiler.stop").then(async ({ profile }) => ({
|
||||
id: await files.save("profile.cpuprofile", "application/json", Buffer.from(JSON.stringify(profile)), [
|
||||
...resources,
|
||||
]),
|
||||
durationMs: (profile.endTime - profile.startTime) / 1000,
|
||||
}))
|
||||
return cpu.result
|
||||
}
|
||||
return {
|
||||
target(type: "trace" | "cpu"): Browser.Target {
|
||||
return {
|
||||
resources: (type === "trace" ? [...traceResources] : [...(cpu?.resources ?? [])]).sort(),
|
||||
key: type === "trace" ? traceID : (cpu?.id ?? ""),
|
||||
}
|
||||
},
|
||||
async startTrace(durationMs = 10_000) {
|
||||
if (recording)
|
||||
throw new Error(
|
||||
recording.owner === contents
|
||||
? "A performance trace is already active in this tab. Use browser.trace.stop({tabID}) to finish it before starting another."
|
||||
: "Another tab owns the active performance trace. Wait for its owner to finish; do not stop or replace another tab's recording.",
|
||||
)
|
||||
const complete = Promise.withResolvers<{ stream?: string; dataLossOccurred: boolean }>()
|
||||
const off = cdp.on("Tracing.tracingComplete", (event) => complete.resolve(event))
|
||||
const owner = {
|
||||
owner: contents,
|
||||
pid: contents.getOSProcessId(),
|
||||
started: performance.now(),
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
finish: () => {
|
||||
if (trace) return trace
|
||||
trace = (async () => {
|
||||
clearTimeout(owner.timer)
|
||||
const durationMs = performance.now() - owner.started
|
||||
const deadline = Promise.withResolvers<never>()
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
deadline.reject(
|
||||
new Error(
|
||||
"Chromium did not finish flushing the trace within 10 seconds. No complete export is confirmed. Check browser.files.list({tabID}); do not start another recording until the current trace has finished or the user resolves the failure.",
|
||||
),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
cdp.send("Tracing.end").then(() => complete.promise),
|
||||
deadline.promise,
|
||||
])
|
||||
if (!result.stream)
|
||||
throw new Error(
|
||||
"Chromium stopped tracing without returning a trace stream. No export is available. Check desktop/plugin compatibility and report the failure; repeating trace.stop cannot recover a missing stream.",
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
try {
|
||||
while (true) {
|
||||
const part = await cdp.send("IO.read", { handle: result.stream, size: 256 * 1024 })
|
||||
const buffer = Buffer.from(part.data, part.base64Encoded ? "base64" : "utf8")
|
||||
bytes += buffer.byteLength
|
||||
if (bytes > 64 * 1024 * 1024)
|
||||
throw new Error(
|
||||
"Trace exceeded its 64 MiB desktop capture limit. Record a shorter interaction with a smaller durationMs in browser.trace.start; do not repeat the same recording unchanged.",
|
||||
)
|
||||
chunks.push(buffer)
|
||||
if (part.eof) break
|
||||
}
|
||||
} finally {
|
||||
await cdp.send("IO.close", { handle: result.stream })
|
||||
}
|
||||
const raw = Schema.decodeUnknownSync(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({ traceEvents: Schema.Array(Schema.Record(Schema.String, Schema.Json)) }),
|
||||
),
|
||||
)(Buffer.concat(chunks).toString("utf8"))
|
||||
// A CDP trace is browser-wide. Export only this renderer process, never
|
||||
// the application renderer or another session's process metadata.
|
||||
const traceEvents = raw.traceEvents.filter((event) => event.pid === owner.pid)
|
||||
const id = await files.save(
|
||||
"trace.json.gz",
|
||||
"application/gzip",
|
||||
gzipSync(
|
||||
JSON.stringify({
|
||||
traceEvents,
|
||||
metadata: { source: "opencode", scope: "renderer-process", processId: owner.pid },
|
||||
}),
|
||||
),
|
||||
[...traceResources],
|
||||
)
|
||||
return {
|
||||
id,
|
||||
durationMs,
|
||||
incomplete:
|
||||
result.dataLossOccurred || contents.isDestroyed() || contents.getOSProcessId() !== owner.pid,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
off()
|
||||
if (recording === owner) recording = undefined
|
||||
}
|
||||
})()
|
||||
return trace
|
||||
},
|
||||
}
|
||||
recording = owner
|
||||
trace = undefined
|
||||
traceResources = new Set(source())
|
||||
traceID = crypto.randomUUID()
|
||||
try {
|
||||
await cdp.send("Tracing.start", {
|
||||
transferMode: "ReturnAsStream",
|
||||
traceConfig: {
|
||||
recordMode: "recordUntilFull",
|
||||
traceBufferSizeInKb: 8192,
|
||||
includedCategories: [
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"v8.execute",
|
||||
"blink.user_timing",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
],
|
||||
excludedCategories: ["*"],
|
||||
},
|
||||
})
|
||||
owner.timer = setTimeout(() => {
|
||||
void owner.finish().catch(() => undefined)
|
||||
}, durationMs)
|
||||
} catch (error) {
|
||||
off()
|
||||
if (recording === owner) recording = undefined
|
||||
throw error
|
||||
}
|
||||
},
|
||||
stopTrace() {
|
||||
if (recording?.owner === contents) return recording.finish()
|
||||
if (trace) return trace
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"This tab has no performance trace to stop. Call browser.trace.start({tabID}), perform the interaction to inspect, then browser.trace.stop({tabID}).",
|
||||
),
|
||||
)
|
||||
},
|
||||
async startCpu() {
|
||||
if (cpu && !cpu.result)
|
||||
throw new Error(
|
||||
"A CPU profile is already active in this tab. Use browser.cpu.stop({tabID}) before starting another profile.",
|
||||
)
|
||||
await cdp.send("Profiler.enable")
|
||||
await cdp.send("Profiler.start")
|
||||
cpu = { started: Date.now(), id: crypto.randomUUID(), resources: new Set(source()) }
|
||||
cpu.timer = setTimeout(() => {
|
||||
void stopCpu().catch(() => undefined)
|
||||
}, 30_000)
|
||||
},
|
||||
stopCpu,
|
||||
async heap() {
|
||||
if (takingHeap)
|
||||
throw new Error(
|
||||
"A heap snapshot is already being captured in this tab. Await that call before taking another; do not capture the same tab's heaps in parallel.",
|
||||
)
|
||||
takingHeap = true
|
||||
const resources = source()
|
||||
const chunks: string[] = []
|
||||
let size = 0
|
||||
let overflow = false
|
||||
const off = cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => {
|
||||
size += Buffer.byteLength(chunk)
|
||||
if (size > 128 * 1024 * 1024) overflow = true
|
||||
if (!overflow) chunks.push(chunk)
|
||||
})
|
||||
try {
|
||||
await cdp.send("HeapProfiler.enable")
|
||||
await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false })
|
||||
} finally {
|
||||
takingHeap = false
|
||||
off()
|
||||
}
|
||||
if (overflow)
|
||||
throw new Error(
|
||||
"Heap snapshot exceeded the 128 MiB desktop capture limit. Use a smaller page/test case or ask the user to inspect the heap with desktop developer tools; this tool has no size override.",
|
||||
)
|
||||
return files.save("heap.heapsnapshot.gz", "application/gzip", gzipSync(chunks.join("")), [
|
||||
...resources,
|
||||
...source(),
|
||||
])
|
||||
},
|
||||
async analyze(
|
||||
action: Extract<
|
||||
Browser.Action,
|
||||
{ type: "trace.analyze" | "cpu.analyze" | "heap.summary" | "heap.query" | "heap.object" | "heap.compare" }
|
||||
>,
|
||||
) {
|
||||
if (action.type === "heap.compare") {
|
||||
const before = parseHeap(await json(action.before)).classes
|
||||
const after = parseHeap(await json(action.after)).classes
|
||||
return {
|
||||
classes: Array.from(new Set([...before.keys(), ...after.keys()]))
|
||||
.map((name) => ({
|
||||
name,
|
||||
countDelta: (after.get(name)?.count ?? 0) - (before.get(name)?.count ?? 0),
|
||||
bytesDelta: (after.get(name)?.bytes ?? 0) - (before.get(name)?.bytes ?? 0),
|
||||
}))
|
||||
.filter((item) => item.countDelta || item.bytesDelta)
|
||||
.sort((a, b) => Math.abs(b.bytesDelta) - Math.abs(a.bytesDelta))
|
||||
.slice(0, action.limit ?? 100),
|
||||
}
|
||||
}
|
||||
const value = await json(action.fileID)
|
||||
if (action.type === "trace.analyze") return analyzeTrace(value, action.limit)
|
||||
if (action.type === "cpu.analyze") return analyzeCpu(value, action.limit)
|
||||
const heap = parseHeap(value)
|
||||
if (action.type === "heap.summary") return heap.summary(action.limit)
|
||||
if (action.type === "heap.query") return heap.query(action.name, action.limit)
|
||||
return heap.object(action.id, action.limit)
|
||||
},
|
||||
async dispose() {
|
||||
if (recording?.owner === contents) await recording.finish().catch(() => undefined)
|
||||
if (cpu && !cpu.result) await stopCpu().catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,35 @@
|
||||
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"
|
||||
import { isRendererUrl } from "../windows/protocol"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const shutdown = yield* Shutdown.Service
|
||||
const browser = createBrowserPane()
|
||||
const stop = Effect.promise(() => browser.dispose())
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
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)
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { BrowserPaneEvent } from "@opencode-ai/app/desktop"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type {
|
||||
ClipboardImage,
|
||||
DirectoryPickerOptions,
|
||||
@@ -23,6 +25,11 @@ export type UpdaterAPI = {
|
||||
export type ElectronAPI = {
|
||||
awaitInitialization(): Promise<ServerReadyData>
|
||||
reconnectService(): Promise<ServerReadyData>
|
||||
browserPane: {
|
||||
request(request: BrowserPaneRequest): Promise<void>
|
||||
send(request: BrowserPaneRequest): void
|
||||
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
|
||||
}
|
||||
wslServers: WslServersAPI
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
|
||||
@@ -25,6 +25,11 @@ const updaterHandler = (state: UpdaterState) => {
|
||||
export const api: ElectronAPI = {
|
||||
awaitInitialization: () => invoke("AppAwaitInitialization"),
|
||||
reconnectService: () => invoke("AppReconnectService"),
|
||||
browserPane: {
|
||||
request: (request) => invoke("BrowserPane", { request }),
|
||||
send: (request) => send("BrowserPane", { request }),
|
||||
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
|
||||
},
|
||||
wslServers: {
|
||||
getState: () => invoke("WslGetState").then(mutable),
|
||||
subscribe: (cb) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user