mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc33c255c | |||
| 11c24c324f |
@@ -613,9 +613,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -903,6 +903,7 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
@@ -1088,9 +1089,9 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -1108,9 +1109,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2168,27 +2169,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -3538,7 +3539,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3622,6 +3623,8 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -4038,7 +4041,7 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -4320,7 +4323,7 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
@@ -4464,6 +4467,8 @@
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -4802,7 +4807,7 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -4928,7 +4933,7 @@
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
@@ -4954,7 +4959,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
@@ -5556,7 +5561,7 @@
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
@@ -5588,6 +5593,8 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -6526,6 +6533,8 @@
|
||||
|
||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
|
||||
|
||||
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
@@ -6710,11 +6719,9 @@
|
||||
|
||||
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
|
||||
|
||||
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
@@ -6790,6 +6797,8 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -7556,6 +7565,20 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -8616,6 +8639,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
+6
-6
@@ -46,9 +46,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -150,9 +150,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||
// for any field name that looks sensitive) plus literal (replace any actual
|
||||
// secret values we sent in the request, in case the response echoes one back).
|
||||
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
|
||||
Array.from(secrets).reduce(
|
||||
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
Array.from(secretValues(request)).reduce(
|
||||
(text, secret) => text.split(secret).join(REDACTED),
|
||||
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||
)
|
||||
|
||||
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
|
||||
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
|
||||
if (body === undefined) return {}
|
||||
const redacted = redactBody(body, secrets)
|
||||
const redacted = redactBody(body, request)
|
||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
@@ -240,7 +240,7 @@ const statusError =
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
const details = responseBody(body, request)
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
@@ -261,42 +261,6 @@ const statusError =
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty, and only structural body redaction applies.
|
||||
export const classifyHttpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,7 +18,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -41,24 +40,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -205,33 +204,7 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Config } from "../../config"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -17,7 +16,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
@@ -41,7 +39,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
config: {
|
||||
update: (update) => runServicePromise(config.update(update)),
|
||||
},
|
||||
paths: { home: global.home, state: global.state, log: global.log },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -128,9 +129,13 @@ export async function usingInteractiveStdin<T>(
|
||||
export function createMiniHost(input: {
|
||||
terminal: InteractiveStdin
|
||||
directory: string
|
||||
paths: { home: string; state: string; log: string }
|
||||
paths?: { home: string; state: string; log: string }
|
||||
}): MiniHost {
|
||||
const paths = input.paths
|
||||
const paths = input.paths ?? {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
log: Global.Path.log,
|
||||
}
|
||||
const diagnostics = {
|
||||
pid: process.pid,
|
||||
cwd: input.directory,
|
||||
|
||||
@@ -22,7 +22,6 @@ export type MiniCommandInput = {
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
config?: MiniFrontendInput["config"]
|
||||
paths: { home: string; state: string; log: string }
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
@@ -105,7 +104,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||
}))
|
||||
const frontend = await frontendTask
|
||||
return frontend.runMiniFrontend({
|
||||
host: createMiniHost({ terminal, directory, paths: input.paths }),
|
||||
host: createMiniHost({ terminal, directory }),
|
||||
sdk,
|
||||
directory,
|
||||
target: resolveTarget,
|
||||
|
||||
@@ -39,8 +39,7 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
|
||||
@@ -11,9 +11,7 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
LanguageModel,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
TransportReason,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
@@ -31,7 +29,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
@@ -725,9 +723,7 @@ function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof AIError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
@@ -735,57 +731,4 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: details.message,
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
responseHeaders: error.responseHeaders,
|
||||
responseBody: error.responseBody,
|
||||
})
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
kind: error.name,
|
||||
url: error.url,
|
||||
http: "http" in reason ? reason.http : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
|
||||
const ProviderErrorDetail = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
})
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
...ProviderErrorDetail.fields,
|
||||
error: Schema.optionalKey(ProviderErrorDetail),
|
||||
})
|
||||
const decodeProviderError = Schema.decodeUnknownOption(
|
||||
Schema.Union([ProviderErrorBody, Schema.fromJsonString(ProviderErrorBody)]),
|
||||
)
|
||||
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.trim() === "" ? "Provider request failed" : message
|
||||
}
|
||||
|
||||
function providerErrorDetails(error: APICallError) {
|
||||
const data = Option.getOrUndefined(decodeProviderError(error.data))
|
||||
const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
|
||||
const details = [data?.error, data, body?.error, body]
|
||||
const message = details.map((detail) => detail?.message).find((value) => value?.trim())
|
||||
const value = details.map((detail) => detail?.code).find((value) => value !== undefined)
|
||||
const code = value === undefined ? undefined : String(value)
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return {
|
||||
code,
|
||||
message:
|
||||
error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
|
||||
}
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
@@ -62,7 +61,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
@@ -113,7 +111,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
location,
|
||||
processes,
|
||||
shell: options,
|
||||
bin: global.bin,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
@@ -167,7 +164,6 @@ function evaluateTemplate(
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -201,16 +197,11 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = ShellSelect.preferred(
|
||||
Config.latest(yield* services.config.entries(), "shell"),
|
||||
services.shell,
|
||||
services.bin,
|
||||
)
|
||||
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
@@ -271,7 +262,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -151,15 +151,7 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
),
|
||||
)
|
||||
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
| { readonly type: "unavailable" }
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
const candidates = [
|
||||
globalFile,
|
||||
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
const file = yield* read(globalFile)
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
|
||||
)
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
|
||||
Effect.as(Instructions.unavailable),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* discovery.transform((draft) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
draft.unavailable()
|
||||
return
|
||||
}
|
||||
for (const file of loaded.current.files) draft.add(file)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
|
||||
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export * as SkillFile from "./skill-file"
|
||||
|
||||
import path from "path"
|
||||
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
|
||||
export type ParseResult =
|
||||
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = Reflect.get(metadata, key)
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parse(directory: string, filepath: string, content: string): ParseResult {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
|
||||
const decoded = decodeFrontmatter(markdown.data)
|
||||
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
|
||||
const frontmatter = decoded.success
|
||||
const id =
|
||||
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(frontmatter.name ?? id),
|
||||
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
||||
...(slash === undefined ? {} : { slash }),
|
||||
...(autoinvoke === undefined ? {} : { autoinvoke }),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,191 +1,64 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "path"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
import { SkillDiscovery } from "../../skill/discovery"
|
||||
import { SkillFile } from "./skill-file"
|
||||
|
||||
type Source = Skill.DirectorySource | Skill.UrlSource
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
|
||||
entries: yield* config.entries(),
|
||||
skills: [],
|
||||
}
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe({ path: target, type })
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{ onlyIfMissing: true, startImmediately: true },
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
|
||||
"ConfigSkillPlugin.watchDirectory",
|
||||
)(function* (directory: string) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) yield* watch(target, "file")
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const sources = () => {
|
||||
const result: Source[] = []
|
||||
const add = (source: Source) => {
|
||||
if (result.some((item) => Skill.Source.equals(item, source))) return
|
||||
result.push(source)
|
||||
}
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const directory of directories) {
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
add(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
add(
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
|
||||
const directories =
|
||||
source.type === "directory"
|
||||
? [source.path]
|
||||
: yield* discovery.pull(source.url).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load skill source", {
|
||||
source: Skill.Source.key(source),
|
||||
cause,
|
||||
}).pipe(Effect.as([] as AbsolutePath[])),
|
||||
),
|
||||
)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const skills: Skill.Info[] = []
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const parsed = SkillFile.parse(directory, filepath, content)
|
||||
if (parsed._tag === "Skipped") {
|
||||
yield* Effect.logDebug("skill file skipped", {
|
||||
filepath,
|
||||
reason: parsed.reason,
|
||||
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
skills.push(parsed.skill)
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Skill.Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
for (const skill of loaded.skills) draft.add(skill)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(refresh()),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
|
||||
readonly changes: () => Stream.Stream<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(fs, location, entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
return operations
|
||||
}),
|
||||
changes: () =>
|
||||
Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
Stream.fromPubSub(configuredChanges),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
|
||||
})
|
||||
|
||||
export const empty = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
operations: () => Effect.succeed([]),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
location: Location.Interface,
|
||||
entries: readonly Entry[],
|
||||
) {
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
@@ -40,19 +40,16 @@ const databaseLayer = Layer.effect(
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
})
|
||||
}
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
|
||||
}
|
||||
|
||||
export const node = configured({ path: ":memory:" })
|
||||
|
||||
+45
-84
@@ -1,86 +1,47 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
@@ -14,7 +13,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -34,10 +33,7 @@ const wellKnownSourcesKey = "wellknown:sources"
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260805200742_import_legacy_credentials",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
|
||||
})
|
||||
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -45,9 +41,9 @@ export default migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
|
||||
if (content === undefined) return
|
||||
const input = Option.getOrUndefined(decodeJson(content))
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
@@ -467,11 +467,10 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
@@ -479,7 +478,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
@@ -493,7 +492,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -503,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
yield* importNextDatabase(db, nextPath(options), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
@@ -622,10 +621,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options, data: string) {
|
||||
function nextPath(options: Options) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(data, "opencode-next.db")
|
||||
return path.join(Global.Path.data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
@@ -26,7 +25,6 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
@@ -44,7 +42,6 @@ const layer = Layer.effect(
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
@@ -125,5 +122,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
})
|
||||
|
||||
@@ -18,10 +18,8 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly bin: string
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findExecutable = (name: string) => which(name, undefined, input.bin)
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
@@ -39,7 +37,7 @@ export function make(input: {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("gofmt")
|
||||
const match = which("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -48,7 +46,7 @@ export function make(input: {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("mix")
|
||||
const match = which("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -151,7 +149,7 @@ export function make(input: {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("zig")
|
||||
const match = which("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -161,7 +159,7 @@ export function make(input: {
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = findExecutable("clang-format")
|
||||
const match = which("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
@@ -170,7 +168,7 @@ export function make(input: {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("ktlint")
|
||||
const match = which("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -179,18 +177,17 @@ export function make(input: {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("ruff")
|
||||
if (!bin) return disabled
|
||||
if (!which("ruff")) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return [bin, "format", "$FILE"]
|
||||
return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
@@ -200,7 +197,7 @@ export function make(input: {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("air")
|
||||
const bin = which("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
@@ -213,34 +210,34 @@ export function make(input: {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("uv")
|
||||
const bin = which("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = findExecutable("ocamlformat")
|
||||
const match = which("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
@@ -256,9 +253,9 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
@@ -290,17 +287,12 @@ export function make(input: {
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(
|
||||
name: string,
|
||||
extensions: readonly string[],
|
||||
args: string[],
|
||||
findExecutable: (name: string) => string | null,
|
||||
): Info {
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable(name)
|
||||
const match = which(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -47,10 +47,6 @@ export const layer = Layer.effect(
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
}),
|
||||
)
|
||||
if (!resolved)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
export * as InstructionDiscovery from "./instruction-discovery"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
@@ -15,30 +17,7 @@ export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = Instructions.Key.make("core/instructions")
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<File>[]
|
||||
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
|
||||
// sibling contributors interleave by transform registration order.
|
||||
add: (file: File) => void
|
||||
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
|
||||
remove: (path: string) => void
|
||||
unavailable: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
// Discovery policy lives here because internal plugins have no per-composition options channel.
|
||||
// Move it into plugin config once plugins can consume their own options.
|
||||
readonly project: boolean
|
||||
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
@@ -53,26 +32,9 @@ export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "instruction-discovery",
|
||||
initial: () => ({ files: new Map(), available: true }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.files.values()),
|
||||
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = draft.files.get(AbsolutePath.make(path))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
},
|
||||
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
|
||||
unavailable: () => {
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<File>>({
|
||||
@@ -87,22 +49,52 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
})
|
||||
|
||||
const list = Effect.fn("InstructionDiscovery.list")(function* () {
|
||||
const current = state.get()
|
||||
if (!current.available) return Instructions.unavailable
|
||||
return Array.from(current.files.values())
|
||||
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const fromProject = relative(stop, start)
|
||||
const insideProject =
|
||||
fromProject === "" ||
|
||||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(
|
||||
options?.project === false || !insideProject
|
||||
? []
|
||||
: yield* fs.up({
|
||||
targets: ["AGENTS.md"],
|
||||
start,
|
||||
stop,
|
||||
}),
|
||||
fs.resolve,
|
||||
),
|
||||
)
|
||||
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
|
||||
const files = yield* Effect.forEach(
|
||||
paths,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(
|
||||
Effect.map((content) =>
|
||||
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||
return Instructions.unavailable
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
project: options?.project !== false,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list,
|
||||
load: Effect.fn("InstructionDiscovery.load")(function* () {
|
||||
const files = yield* list()
|
||||
if (!Array.isArray(files)) return source(files)
|
||||
return source(files.length === 0 ? Instructions.removed : files)
|
||||
}),
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -111,7 +103,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node],
|
||||
deps: [FSUtil.node, Global.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,24 +46,7 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass<Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
export class UnresolvedProviderVariablesError extends Schema.TaggedErrorClass<UnresolvedProviderVariablesError>()(
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
variables: Schema.Array(Schema.String),
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.variables.join(", ")} ${this.variables.length === 1 ? "is" : "are"} required to resolve the provider endpoint`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| UnresolvedProviderVariablesError
|
||||
| Integration.AuthorizationError
|
||||
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
||||
@@ -158,17 +141,12 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
|
||||
const prepared = prepareRuntimeModel(model, credential)
|
||||
if (prepared.unresolved.length > 0)
|
||||
return Effect.fail(
|
||||
new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: prepared.unresolved,
|
||||
}),
|
||||
)
|
||||
const resolved = prepared.model
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
@@ -248,26 +226,6 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
const prepared = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
|
||||
return process.env[name] ?? placeholder
|
||||
})
|
||||
})
|
||||
const baseURL = prepared.settings?.baseURL
|
||||
const unresolved =
|
||||
typeof baseURL === "string"
|
||||
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
|
||||
(name, index, names) => names.indexOf(name) === index,
|
||||
)
|
||||
: []
|
||||
return { model: prepared, unresolved }
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bus } from "./bus"
|
||||
@@ -10,7 +13,6 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { KV } from "./kv"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -535,18 +537,6 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -554,7 +544,6 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -565,28 +554,21 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
)
|
||||
|
||||
const source = options?.url || defaultSource
|
||||
const source = options?.url || "https://models.opencode.ai"
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const key = cacheKey(source)
|
||||
const filepath = path.join(
|
||||
Global.Path.cache,
|
||||
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||
)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
const lockKey = `models-dev:${filepath}`
|
||||
|
||||
const fresh = Effect.fnUntraced(function* () {
|
||||
const cached = yield* loadFromCache()
|
||||
if (!cached) return false
|
||||
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
|
||||
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stat) return false
|
||||
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
|
||||
return Date.now() - mtime < Duration.toMillis(ttl)
|
||||
})
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
@@ -598,12 +580,15 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch((error) => {
|
||||
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
|
||||
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
|
||||
}
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
const loadSnapshot = Effect.sync(() =>
|
||||
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
|
||||
@@ -611,27 +596,33 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text })
|
||||
return catalog
|
||||
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
|
||||
yield* fs.writeWithDirs(tempfile, text).pipe(
|
||||
Effect.andThen(fs.rename(tempfile, filepath)),
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return text
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const fromDisk = yield* loadFromDisk
|
||||
if (fromDisk) return normalize(fromDisk)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return normalize(bundled)
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
|
||||
const text = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
yield* Flock.effect(lockKey)
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(catalog)
|
||||
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
@@ -639,19 +630,21 @@ export const layer = (options?: Options) =>
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Flock.effect(lockKey)
|
||||
// Re-check under the lock: another process may have refreshed between
|
||||
// our outer check and lock acquisition.
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
if (fetch && !process.argv.includes("--get-yargs-completions")) {
|
||||
@@ -667,7 +660,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -290,10 +290,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(draft.list()),
|
||||
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
|
||||
list: draft.list,
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../agent"
|
||||
@@ -12,7 +10,6 @@ import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
@@ -27,7 +24,6 @@ import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
@@ -41,8 +37,6 @@ import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { SkillDiscovery } from "../skill/discovery"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { PatchTool } from "../tool/plugin/patch"
|
||||
import { EditTool } from "../tool/plugin/edit"
|
||||
import { GlobTool } from "../tool/plugin/glob"
|
||||
@@ -85,7 +79,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const instructionDiscovery = yield* InstructionDiscovery.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -102,9 +95,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(Agent.Service, agent),
|
||||
@@ -121,7 +112,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Global.Service, global),
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(InstructionDiscovery.Service, instructionDiscovery),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
@@ -138,9 +128,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
@@ -149,44 +137,6 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
InstructionDiscovery.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
@@ -214,7 +164,6 @@ const pre = [
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
const post = [
|
||||
ConfigInstructionPlugin.Plugin,
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
|
||||
@@ -59,8 +59,36 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return structuredClone(data).filter(
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
return (
|
||||
structuredClone(data)
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
.filter(
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
.map((provider) => {
|
||||
const environment = new Set(provider.environment)
|
||||
return {
|
||||
...provider,
|
||||
info: {
|
||||
...provider.info,
|
||||
...(provider.info.settings ? { settings: resolveEnvironment(provider.info.settings, environment) } : {}),
|
||||
},
|
||||
models: provider.models.map((model) => ({
|
||||
...model,
|
||||
...(model.settings ? { settings: resolveEnvironment(model.settings, environment) } : {}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEnvironment(settings: Readonly<Record<string, unknown>>, environment: Set<string>) {
|
||||
if (typeof settings.baseURL !== "string") return settings
|
||||
return {
|
||||
...settings,
|
||||
baseURL: settings.baseURL.replace(/\$\{([^}]+)\}/g, (value, name: string) => {
|
||||
if (!environment.has(name)) return value
|
||||
return process.env[name] ?? value
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,23 +28,29 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Plugin } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
@@ -32,10 +63,65 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -82,9 +168,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
@@ -108,6 +192,31 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
@@ -115,29 +224,72 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const operations = yield* sources.operations()
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -163,13 +315,48 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: nodeLayer,
|
||||
deps: [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
export { layer }
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { lazy } from "./util/lazy"
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
@@ -97,7 +96,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
@@ -167,8 +165,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command =
|
||||
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
|
||||
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
@@ -318,11 +315,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node],
|
||||
})
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -34,8 +34,6 @@ export namespace RipgrepBinary {
|
||||
const fs = yield* FSUtil.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const global = yield* Global.Service
|
||||
const findExecutable = (name: string) => which(name, undefined, global.bin)
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
|
||||
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
|
||||
@@ -55,12 +53,10 @@ export namespace RipgrepBinary {
|
||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||
target: string,
|
||||
) {
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||
|
||||
if (config.extension === "zip") {
|
||||
const shell =
|
||||
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
|
||||
"powershell.exe"
|
||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||
const result = yield* run(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -95,10 +91,10 @@ export namespace RipgrepBinary {
|
||||
return Service.of({
|
||||
filepath: yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||
|
||||
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
@@ -107,10 +103,10 @@ export namespace RipgrepBinary {
|
||||
|
||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||
const archive = path.join(global.bin, filename)
|
||||
const archive = path.join(Global.Path.bin, filename)
|
||||
|
||||
yield* Effect.logInfo("downloading ripgrep", { url })
|
||||
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
|
||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
@@ -131,6 +127,6 @@ export namespace RipgrepBinary {
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
|
||||
export type Resolved = ModelResolver.Resolved
|
||||
|
||||
@@ -52,8 +52,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
|
||||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
|
||||
@@ -143,9 +143,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
|
||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
|
||||
@@ -34,19 +34,15 @@ function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function findExecutable(name: string, bin?: string) {
|
||||
return which(name, undefined, bin)
|
||||
}
|
||||
|
||||
function full(file: string, options?: Options, bin?: string) {
|
||||
function full(file: string, options?: Options) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
|
||||
return shell
|
||||
}
|
||||
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
|
||||
return findExecutable(shell, bin) || shell
|
||||
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function meta(file: string) {
|
||||
@@ -61,26 +57,21 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
function resolve(file: string, options?: Options) {
|
||||
const shell = full(file, options)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return findExecutable(shell, bin) ?? undefined
|
||||
return which(shell) ?? undefined
|
||||
}
|
||||
|
||||
function win(options?: Options, bin?: string) {
|
||||
function win(options?: Options) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
findExecutable("pwsh", bin),
|
||||
findExecutable("powershell", bin),
|
||||
gitbash(options, bin),
|
||||
process.env.COMSPEC || "cmd.exe",
|
||||
]
|
||||
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map((file) => full(file, options, bin)),
|
||||
.map((file) => full(file, options)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -91,27 +82,27 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options, bin)
|
||||
const shell = resolve(file, options)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
return fallback(bin)
|
||||
if (process.platform === "win32") return win(options)[0]
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash(options?: Options, bin?: string) {
|
||||
export function gitbash(options?: Options) {
|
||||
if (process.platform !== "win32") return
|
||||
if (options?.gitbash) return options.gitbash
|
||||
const git = findExecutable("git", bin)
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback(bin?: string) {
|
||||
function fallback() {
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = findExecutable("bash", bin)
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
@@ -129,12 +120,12 @@ export function ps(file: string) {
|
||||
return meta(file)?.ps === true
|
||||
}
|
||||
|
||||
function info(file: string, options?: Options, bin?: string): Item {
|
||||
const item = full(file, options, bin)
|
||||
function info(file: string, options?: Options): Item {
|
||||
const item = full(file, options)
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
name: resolve(n, options) ? n : item,
|
||||
acceptable: ok(item),
|
||||
}
|
||||
}
|
||||
@@ -148,36 +139,30 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
return value
|
||||
export function preferred(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options)
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
export function acceptable(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true })
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
|
||||
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
export async function list(options?: Options): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
|
||||
}
|
||||
|
||||
+192
-23
@@ -2,12 +2,17 @@ export * as Skill from "./skill"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
import { Bus } from "./bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Permission } from "./permission"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SkillDiscovery } from "./skill/discovery"
|
||||
import { State } from "./state"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
|
||||
export const DirectorySource = Skill.DirectorySource
|
||||
export type DirectorySource = Skill.DirectorySource
|
||||
@@ -52,18 +57,38 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = (metadata as { readonly [key: string]: unknown })[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
skills: Map<ID, Types.DeepMutable<Info>>
|
||||
sources: Types.DeepMutable<Source>[]
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<Info>[]
|
||||
add: (skill: Info) => void
|
||||
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: string) => void
|
||||
source: (source: Source) => void
|
||||
list: () => readonly Source[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly sources: () => Effect.Effect<Source[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -72,35 +97,179 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const changed = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return false
|
||||
cache.clear()
|
||||
yield* FiberMap.clear(watches)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!changed) return
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe(
|
||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
||||
)
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
||||
directory: string,
|
||||
) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) {
|
||||
yield* watch(target, "file")
|
||||
}
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
initial: () => ({ skills: new Map() }),
|
||||
initial: () => ({ sources: [] }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.skills.values()),
|
||||
add: (skill) => {
|
||||
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.skills.get(ID.make(id))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.id = ID.make(id)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.skills.delete(ID.make(id))
|
||||
source: (source) => {
|
||||
if (draft.sources.some((item) => Source.equals(item, source))) return
|
||||
draft.sources.push(source as Types.DeepMutable<Source>)
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () =>
|
||||
lock
|
||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||
const skills: Info[] = []
|
||||
if (source.type === "embedded") {
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], paths: [] }
|
||||
}
|
||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const paths = [...roots]
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
||||
const external = path.dirname(resolved)
|
||||
paths.push(external)
|
||||
yield* watch(external, "directory")
|
||||
}
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) continue
|
||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||
if (!frontmatter) continue
|
||||
const id =
|
||||
path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
skills.push({
|
||||
id: ID.make(id),
|
||||
name: Name.make(frontmatter.name ?? id),
|
||||
description: frontmatter.description,
|
||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, paths }
|
||||
})
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Skill.list")(function* () {
|
||||
return Array.from(state.get().skills.values())
|
||||
sources: Effect.fn("Skill.sources")(function* () {
|
||||
return state.get().sources
|
||||
}),
|
||||
list,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -108,5 +277,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node],
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base && bin ? base + path.delimiter + bin : base || bin
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: full,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
|
||||
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { expect } from "bun:test"
|
||||
@@ -340,170 +337,3 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
const failingModel = (failure: unknown): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () => Promise.reject(failure),
|
||||
})
|
||||
|
||||
const streamFailure = (failure: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => failingModel(failure) }
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves non-empty AI SDK error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(new Error("Bad Request"))
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
|
||||
}),
|
||||
)
|
||||
|
||||
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
|
||||
new APICallError({
|
||||
message: "",
|
||||
url: "https://api.example.com/chat",
|
||||
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
||||
responseHeaders: { authorization: "Bearer secret-token" },
|
||||
...input,
|
||||
})
|
||||
|
||||
it.effect("derives status and code when the AI SDK error message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
data: { error: { message: "", code: "not_found" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
|
||||
expect(error.reason.message).not.toContain("secret-token")
|
||||
expect(error.reason.message).not.toContain("private prompt")
|
||||
const projected = toSessionError(error)
|
||||
expect(projected.type).toBe("provider.invalid-request")
|
||||
expect(projected.status).toBe(404)
|
||||
expect(projected.message).not.toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
const http = "http" in error.reason ? error.reason.http : undefined
|
||||
expect(http?.request.url).toBe("https://api.example.com/chat")
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 429,
|
||||
responseHeaders: { "retry-after": "7" },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "api_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies data-only AI SDK authentication errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "authentication_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("detects context overflow from data-only AI SDK errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "context_length_exceeded" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(isContextOverflowFailure(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries status-less AI SDK transport failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
message: "Cannot connect to API: connection refused",
|
||||
isRetryable: true,
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
data: { error: { code: "not_found" } },
|
||||
responseBody: '{"message":"The requested model does not exist"}',
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("The requested model does not exist")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the status alone for malformed response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 502,
|
||||
isRetryable: false,
|
||||
responseBody: "<html>Bad Gateway</html>",
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,6 @@ Use native v2 fields.`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
|
||||
@@ -185,7 +185,6 @@ Review files`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
|
||||
it.live("loads authenticated wellknown config before user configuration", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
@@ -322,7 +322,6 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
const integrationID = Integration.ID.make("https://example.com")
|
||||
let available = false
|
||||
let key = "secret"
|
||||
const credentialNode = makeGlobalNode({
|
||||
service: Credential.Service,
|
||||
@@ -362,10 +361,7 @@ describe("Config", () => {
|
||||
refresh: () => Effect.succeed(false),
|
||||
add: () => Effect.die("unused Wellknown.add"),
|
||||
remove: () => Effect.die("unused Wellknown.remove"),
|
||||
resolve: (_entry, variables) =>
|
||||
available
|
||||
? Effect.succeed([{ shell: variables.TOKEN }])
|
||||
: Effect.fail(new Error("expired credential")),
|
||||
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
@@ -378,12 +374,11 @@ describe("Config", () => {
|
||||
expect(Config.latest(initial, "shell")).toBe("project")
|
||||
expect(
|
||||
initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
|
||||
).toEqual(["global", "project"])
|
||||
).toEqual(["secret", "global", "project"])
|
||||
const updated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
available = true
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: first
|
||||
description: First skill
|
||||
---
|
||||
|
||||
# first
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: second
|
||||
description: Second skill
|
||||
---
|
||||
|
||||
# second
|
||||
@@ -5,10 +5,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -21,19 +19,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
@@ -168,29 +157,6 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
|
||||
@@ -46,7 +46,9 @@ describe("config plugin reloads", () => {
|
||||
|
||||
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
|
||||
|
||||
@@ -67,8 +69,12 @@ describe("config plugin reloads", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
Effect.provide(Config.testLayer([config("first")])),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
|
||||
@@ -83,7 +89,7 @@ function config(name: string) {
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
|
||||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
}),
|
||||
|
||||
@@ -1,379 +1,87 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory as ConfigDirectory,
|
||||
Document,
|
||||
type Entry,
|
||||
Info,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
const failedUrls = new Set<string>()
|
||||
let pulls = 0
|
||||
const discoveryLayer = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
||||
discoveryLayer,
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
||||
const service = yield* Skill.Service
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: service.transform,
|
||||
reload: service.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer(entries)),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const start = (skills: string[], directory: string) =>
|
||||
startEntries(
|
||||
[
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ skills }),
|
||||
}),
|
||||
],
|
||||
directory,
|
||||
)
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
it.effect("parses root and nested skill ids and metadata flags", () =>
|
||||
Effect.sync(() => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.live("maps config entry types to skill directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const claude = path.join(tmp.path, "claude")
|
||||
const agents = path.join(tmp.path, "agents")
|
||||
const opencode = path.join(tmp.path, "opencode")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const expected = [
|
||||
path.join(claude, "skills"),
|
||||
path.join(agents, "skills"),
|
||||
path.join(opencode, "skill"),
|
||||
path.join(opencode, "skills"),
|
||||
path.join(home, "shared"),
|
||||
path.join(directory, "relative"),
|
||||
]
|
||||
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const sources: Skill.Source[] = []
|
||||
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
|
||||
const result = update({
|
||||
source: (source) => {
|
||||
sources.push(source)
|
||||
},
|
||||
list: () => sources,
|
||||
})
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
const dispose = Effect.sync(() => {
|
||||
sources.length = 0
|
||||
})
|
||||
yield* Effect.addFinalizer(() => dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
yield* startEntries(
|
||||
[
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
|
||||
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
||||
],
|
||||
directory,
|
||||
home,
|
||||
)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads directory and URL sources with later-source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
|
||||
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
||||
expect(pulls).toBe(1)
|
||||
expect(sources).toEqual([
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Available")
|
||||
})
|
||||
const url = "https://unreachable.example.test/skills/"
|
||||
failedUrls.add(url)
|
||||
|
||||
const skill = yield* start([tmp.path, url], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
|
||||
failedUrls.delete(url)
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rescans directory sources when watched files change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial")
|
||||
})
|
||||
const skill = yield* start([tmp.path], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("follows missing source directories as their parents appear", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
|
||||
await write(source, "deploy", "Deploy")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
|
||||
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,19 +11,11 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
|
||||
const run = <A, E>(
|
||||
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
|
||||
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
|
||||
) =>
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
Effect.scoped,
|
||||
),
|
||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -39,7 +31,7 @@ describe("DatabaseMigration", () => {
|
||||
Effect.scoped(Layer.build(layer)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -135,8 +127,7 @@ describe("DatabaseMigration", () => {
|
||||
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
|
||||
|
||||
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
|
||||
[
|
||||
@@ -168,28 +159,11 @@ describe("DatabaseMigration", () => {
|
||||
value: JSON.stringify(["https://example.com"]),
|
||||
})
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
|
||||
expect(await Bun.file(source).text()).toBe(content)
|
||||
})
|
||||
|
||||
test("skips legacy credential import when the source file is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
})
|
||||
|
||||
test("rolls back a failed migration without recording it", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export const tempGlobalLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const data = path.join(tmp.path, "data")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
return Global.layerWith({
|
||||
home: path.join(tmp.path, "home"),
|
||||
data,
|
||||
cache,
|
||||
config: path.join(tmp.path, "config"),
|
||||
state: path.join(tmp.path, "state"),
|
||||
tmp: path.join(tmp.path, "tmp"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
repos: path.join(data, "repos"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
test("tmp path is created on module load", async () => {
|
||||
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,124 +1,49 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const instructionLayer = (input: {
|
||||
config?: string
|
||||
config: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
project?: boolean
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
}) =>
|
||||
AppNodeBuilder.build(InstructionDiscovery.node, [
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
])
|
||||
|
||||
describe("InstructionDiscovery", () => {
|
||||
it.effect("stores ordered values with last-write-wins precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/repo/AGENTS.md", "first"))
|
||||
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||
draft.add(file("/repo/AGENTS.md", "last"))
|
||||
draft.update("/repo/packages/AGENTS.md", (current) => {
|
||||
current.content = "updated"
|
||||
current.path = AbsolutePath.make("/ignored")
|
||||
})
|
||||
draft.remove("/missing")
|
||||
})
|
||||
|
||||
expect(yield* discovery.list()).toEqual([
|
||||
file("/repo/AGENTS.md", "last"),
|
||||
file("/repo/packages/AGENTS.md", "updated"),
|
||||
])
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted values while the source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => draft.unavailable())
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
it.live("loads global and upward project files and rescans them on change", () =>
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
@@ -128,15 +53,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const discovery = yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: globalFile, type: "file" },
|
||||
{ path: packageFile, type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: projectFile, type: "file" },
|
||||
])
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
const load = InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const initialized = yield* readInitial(yield* load)
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
@@ -147,14 +82,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
|
||||
`Instructions from: ${packageFile}\nchanged`,
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
const partial = yield* readUpdate(yield* load, initialized)
|
||||
expect(partial.text).toBe(
|
||||
[
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
@@ -162,30 +96,12 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(globalFile))
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
yield* Effect.promise(() => fs.rm(projectFile))
|
||||
yield* emitAndWait({ type: "delete", path: projectFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
|
||||
"Previously loaded instructions no longer apply.",
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -198,150 +114,115 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers a newly created instruction file in an intermediate directory", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const intermediate = path.join(project, "packages", "AGENTS.md")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
|
||||
yield* emitAndWait({ type: "create", path: intermediate })
|
||||
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
|
||||
"\n\n",
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("isolates source failure without failing activation", () => {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
it.effect("preserves admitted instructions while observation is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("marks a discovered file that disappears before read as unavailable", () => {
|
||||
const discovered = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([discovered]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
it.effect("canonicalizes boundaries and honors project opt-out", () =>
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
|
||||
.changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
|
||||
const file = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([file]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("canonicalizes upward discovery boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: { targets: string[]; start: string; stop?: string } | undefined
|
||||
const observingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
|
||||
up: (options) =>
|
||||
Effect.sync(() => {
|
||||
observed = options
|
||||
return []
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* start().pipe(
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -352,11 +233,31 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* start().pipe(
|
||||
|
||||
expect(observed).toEqual({
|
||||
targets: ["AGENTS.md"],
|
||||
start: FSUtil.resolve("/repo"),
|
||||
stop: FSUtil.resolve("/repo"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors the project instruction opt-out", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: observingFS,
|
||||
config: "/global",
|
||||
project: false,
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
@@ -364,10 +265,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* start().pipe(
|
||||
|
||||
expect(scanned).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: observingFS,
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -381,8 +297,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const repo = path.resolve("/repo")
|
||||
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
|
||||
expect(scanned).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -17,7 +16,6 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -31,7 +29,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -51,7 +49,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -22,7 +21,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
@@ -30,15 +28,9 @@ import { Bus } from "../src/bus"
|
||||
import { Reference } from "../src/reference"
|
||||
import { Tool } from "../src/tool"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
|
||||
@@ -42,27 +42,6 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
limit: options.limit ?? { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -251,42 +230,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider URLs from environment without mutating the catalog model", () =>
|
||||
withEnv({ ACME_HOST: "api.acme.test" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://${ACME_HOST}/v1" },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://api.acme.test/v1")
|
||||
expect(catalog.settings?.baseURL).toBe("https://${ACME_HOST}/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved provider URL variables before route construction", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/${REQUIRED_PATH}/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
variables: ["REQUIRED_HOST", "REQUIRED_PATH"],
|
||||
})
|
||||
expect(failure.message).toBe(
|
||||
"Cannot initialize test-provider/test-model: REQUIRED_HOST, REQUIRED_PATH are required to resolve the provider endpoint",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { it } from "./lib/effect"
|
||||
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const cacheKey = "models-dev:catalog"
|
||||
const cacheFile = path.join(Global.Path.cache, "models.json")
|
||||
|
||||
test("normalizes permissive interleaved values to compatibility", () => {
|
||||
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
|
||||
@@ -162,18 +164,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
}),
|
||||
)
|
||||
|
||||
interface MockCache {
|
||||
readonly values: Map<string, KV.Value>
|
||||
}
|
||||
|
||||
const makeMockKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
|
||||
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
@@ -181,20 +172,31 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
]),
|
||||
)
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
const writeCacheText = (text: string, mtimeMs?: number) =>
|
||||
Effect.promise(async () => {
|
||||
await mkdir(Global.Path.cache, { recursive: true })
|
||||
await writeFile(cacheFile, text)
|
||||
if (mtimeMs !== undefined) {
|
||||
const t = mtimeMs / 1000
|
||||
await utimes(cacheFile, t, t)
|
||||
}
|
||||
})
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, body: text })
|
||||
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state)))
|
||||
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state, cache)))
|
||||
beforeEach(async () => {
|
||||
await rm(cacheFile, { force: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(cacheFile, { force: true })
|
||||
})
|
||||
|
||||
const initialState: MockState = {
|
||||
body: JSON.stringify(fixture),
|
||||
@@ -203,14 +205,12 @@ const initialState: MockState = {
|
||||
}
|
||||
|
||||
describe("ModelsDev Service", () => {
|
||||
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
|
||||
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result).toEqual(fixtureSnapshot)
|
||||
@@ -219,13 +219,11 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
|
||||
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
@@ -234,15 +232,14 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
|
||||
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
yield* writeCacheText("{")
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
|
||||
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
@@ -250,10 +247,9 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* ModelsDev.Service.use((service) => service.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
|
||||
Effect.provide(buildLayer(state, { url: "", fetch: true })),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
|
||||
}),
|
||||
@@ -261,31 +257,32 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("get() is single-flight under concurrent calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const results = yield* Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
|
||||
const results = yield* provided(
|
||||
state,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
}),
|
||||
)
|
||||
for (const result of results) expect(result).toEqual(fixtureSnapshot)
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
|
||||
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const first = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const a = yield* svc.get()
|
||||
writeCache(cache, fixture2)
|
||||
// mutate disk between calls — cache should mask the change
|
||||
yield* writeCache(fixture2)
|
||||
const b = yield* svc.get()
|
||||
return { a, b }
|
||||
}),
|
||||
@@ -297,12 +294,10 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const before = yield* svc.get()
|
||||
@@ -313,7 +308,6 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
expect(result.before).toEqual(fixtureSnapshot)
|
||||
expect(result.after).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
@@ -321,14 +315,13 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
|
||||
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 1000)
|
||||
// Fresh: mtime within the 5-minute TTL.
|
||||
yield* writeCache(fixture, Date.now() - 1000)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.refresh(false)),
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
@@ -336,14 +329,13 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) fetches when the KV entry is stale", () =>
|
||||
it.live("refresh(false) fetches when on-disk file is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
|
||||
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const after = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
yield* svc.refresh(false)
|
||||
@@ -358,12 +350,10 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
yield* svc.refresh(true)
|
||||
|
||||
@@ -19,11 +19,9 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
@@ -55,10 +53,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
PluginHooks.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
PluginHooks.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
|
||||
@@ -242,7 +242,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
|
||||
)
|
||||
|
||||
it.effect("preserves provider and model URL templates in the catalog", () =>
|
||||
it.effect("resolves declared environment variables in provider and model URLs", () =>
|
||||
withEnv(
|
||||
{
|
||||
ACME_HOST: "api.acme.test",
|
||||
@@ -298,10 +298,10 @@ describe("ModelsDevPlugin", () => {
|
||||
)
|
||||
|
||||
expect((yield* catalog.provider.get(providerID))?.settings?.baseURL).toBe(
|
||||
"https://${ACME_HOST}/${UNDECLARED_HOST}/v1",
|
||||
"https://api.acme.test/${UNDECLARED_HOST}/v1",
|
||||
)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe(
|
||||
"https://${ACME_HOST}/${ACME_MODEL_PATH}/v1",
|
||||
"https://api.acme.test/${ACME_MODEL_PATH}/v1",
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -543,10 +543,11 @@ describe("Session.create", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Database.node, targetDatabase],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -19,9 +19,6 @@ import {
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
@@ -94,19 +91,6 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves unresolved provider endpoint errors", () => {
|
||||
const error = new ModelResolver.UnresolvedProviderVariablesError({
|
||||
providerID: Provider.ID.make("cloudflare-workers-ai"),
|
||||
modelID: ID.make("model"),
|
||||
variables: ["CLOUDFLARE_ACCOUNT_ID"],
|
||||
})
|
||||
expect(toSessionError(error)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message:
|
||||
"Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -99,10 +99,7 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
}),
|
||||
),
|
||||
})
|
||||
const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
|
||||
@@ -83,10 +83,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
),
|
||||
})
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
|
||||
@@ -312,10 +312,7 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
|
||||
}),
|
||||
),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -1,102 +1,438 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
let pulls = 0
|
||||
const discovery = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
|
||||
[SkillDiscovery.node, discovery],
|
||||
[Watcher.node, watcherLayer],
|
||||
]),
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const info = (id: string, description: string) =>
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(id),
|
||||
description,
|
||||
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
|
||||
content: `# ${id}`,
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
function waitForSkillUpdate() {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
return { deferred, fiber }
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe("Skill", () => {
|
||||
it.effect("registers values with last-write-wins precedence", () =>
|
||||
it.live("publishes updates when skill sources change", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((draft) => {
|
||||
draft.add(info("review", "First"))
|
||||
draft.add(info("deploy", "Deploy"))
|
||||
draft.add(info("review", "Second"))
|
||||
expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")])
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
skill
|
||||
.transform((editor) =>
|
||||
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
|
||||
)
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates and removes registered values", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((draft) => {
|
||||
draft.add(info("review", "Initial"))
|
||||
draft.update("review", (value) => {
|
||||
value.description = "Updated"
|
||||
value.id = Skill.ID.make("ignored")
|
||||
})
|
||||
draft.update("missing", () => Effect.die("unreachable"))
|
||||
draft.add(info("deploy", "Deploy"))
|
||||
draft.remove("deploy")
|
||||
})
|
||||
it.live("registers sources and resolves later source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([info("review", "Updated")])
|
||||
}),
|
||||
)
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => {
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(second) })
|
||||
expect(editor.list()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("restores earlier values when an updating transform is disposed", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const original = info("review", "Initial")
|
||||
yield* skill.transform((draft) => draft.add(original))
|
||||
const updated = yield* skill.transform((draft) =>
|
||||
draft.update("review", (value) => {
|
||||
value.description = "Updated"
|
||||
expect(yield* skill.sources()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
expect(yield* skill.list()).toEqual([
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("foo"),
|
||||
name: Skill.Name.make("foo"),
|
||||
slash: true,
|
||||
location: AbsolutePath.make(path.join(first, "foo.md")),
|
||||
content: "# foo",
|
||||
}),
|
||||
{
|
||||
id: Skill.ID.make("review"),
|
||||
name: Skill.Name.make("review"),
|
||||
description: "Second",
|
||||
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
||||
content: "# review",
|
||||
},
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => write(second, "review", "Updated Second"))
|
||||
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* skill.list())[0]?.description).toBe("Updated")
|
||||
yield* updated.dispose
|
||||
expect((yield* skill.list())[0]?.description).toBe("Initial")
|
||||
expect(original.description).toBe("Initial")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes updates after committed values are visible", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<Skill.Info[]>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
it.live("loads URL sources and filters skills for agents", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Deploy production")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
|
||||
|
||||
yield* skill.transform((draft) => draft.add(info("review", "Visible")))
|
||||
expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")])
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}),
|
||||
)
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters values by agent permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
|
||||
expect(pulls).toBe(1)
|
||||
expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
|
||||
}),
|
||||
)
|
||||
const agent = yield* agents.get(Agent.ID.make("reviewer"))
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("parses opencode metadata flags from skill frontmatter", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "manual", "SKILL.md"),
|
||||
`---
|
||||
name: manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: true
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
|
||||
expect(yield* skill.list()).toEqual([
|
||||
{
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
|
||||
content: "# manual",
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("clears cached skills when sources reload", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
|
||||
|
||||
let refreshed: Skill.Info[] = []
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (event.type !== Skill.Event.Updated.type) return Effect.void
|
||||
return skill.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* skill.reload().pipe(Effect.timeout("1 second"))
|
||||
yield* unsubscribe
|
||||
|
||||
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: tmp.path, type: "directory" },
|
||||
{ path: tmp.path, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads project sources created after their missing parent", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const file = path.join(source, "deploy", "SKILL.md")
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||
await write(source, "deploy", "Deploy production")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: source, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches directory sources for added and changed skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review changes")
|
||||
})
|
||||
const review = path.join(tmp.path, "review", "SKILL.md")
|
||||
yield* emitAndWait({ type: "create", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"))
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("invalidates symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -139,7 +138,6 @@ const layer = AppNodeBuilder.build(
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -288,30 +286,27 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"permissions compound commands separately",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("SkillTool", () => {
|
||||
Skill.Service.of({
|
||||
transform: (_transform) => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
sources: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed(current),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "path"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -27,7 +26,6 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -102,10 +100,7 @@ const layer = AppNodeBuilder.build(
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
[[SessionExecution.node, executionNode]],
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
||||
@@ -18,14 +18,9 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import path from "path"
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
effect.pipe(
|
||||
Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
),
|
||||
),
|
||||
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
|
||||
)
|
||||
|
||||
const session = (
|
||||
@@ -777,7 +772,7 @@ describe("V1Migration database workflow", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
|
||||
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
@@ -940,7 +935,6 @@ describe("V1Migration database workflow", () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
|
||||
)
|
||||
@@ -950,7 +944,7 @@ describe("V1Migration database workflow", () => {
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
|
||||
worktree: path.parse(global.data).root,
|
||||
worktree: path.parse(Global.Path.data).root,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
|
||||
value: '{"phase":"completed"}',
|
||||
|
||||
@@ -44,10 +44,6 @@ export interface MermaidMarkdownRendererOptions {
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
request?: ColorInput
|
||||
response?: ColorInput
|
||||
note?: ColorInput
|
||||
noteBackground?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +131,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.request ?? colors.primary),
|
||||
response: color(colors.response ?? colors.primary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.note ?? colors.warning),
|
||||
noteBg: color(colors.noteBackground ?? colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
@@ -32,15 +31,11 @@ describe("OpenCode diagram palette", () => {
|
||||
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const success = RGBA.fromInts(80, 180, 120)
|
||||
const warning = RGBA.fromInts(220, 160, 80)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
background,
|
||||
})
|
||||
|
||||
@@ -50,9 +45,5 @@ describe("OpenCode diagram palette", () => {
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
expect(palette.request).toBe(success)
|
||||
expect(palette.response).toBe(warning)
|
||||
expect(palette.note).toBe(primary)
|
||||
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly success: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
@@ -18,9 +16,5 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
request: input.success,
|
||||
response: input.warning,
|
||||
note: input.text,
|
||||
noteBackground: blendColor(input.background, input.subdued, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ export default Plugin.define({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
success: context.theme.text.feedback.success.default,
|
||||
warning: context.theme.text.feedback.warning.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -51,15 +51,16 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
Browser Server
|
||||
───┬─── ───┬──
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -139,13 +140,13 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[0]!.indexOf("w")
|
||||
const serverCenter = lines[0]!.indexOf("v")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[1]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[2]?.[browserCenter]).toBe("│")
|
||||
expect(lines[1]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[2]?.[serverCenter]).toBe("│")
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
@@ -276,8 +277,8 @@ sequenceDiagram
|
||||
A->>B: hello`)
|
||||
|
||||
expect(output).not.toContain("<br")
|
||||
expect(output).toContain("First line")
|
||||
expect(output).toContain("Second line")
|
||||
expect(output).toContain("│ First line │")
|
||||
expect(output).toContain("│ Second line │")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
@@ -313,27 +314,28 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" A B
|
||||
─┬─ ─┬─
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -385,7 +387,7 @@ sequenceDiagram
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
@@ -555,7 +557,7 @@ sequenceDiagram
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
@@ -598,7 +600,7 @@ sequenceDiagram
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("─")
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
@@ -653,17 +655,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ───────────────────────────────╮
|
||||
Browser │ API Cache DB │
|
||||
───┬─── │ ─┬─ ──┬── ─┬─ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────╯"
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -703,17 +706,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"Service
|
||||
───┬───
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders note badges in their reserved rows", () => {
|
||||
test("frames notes in their reserved rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
@@ -725,9 +729,11 @@ sequenceDiagram
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow]).toContain(" phase ")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 1]).toContain("╭")
|
||||
expect(lines[noteRow - 1]).toContain("╮")
|
||||
expect(lines[noteRow]).toContain("│ phase │")
|
||||
expect(lines[noteRow + 1]).toContain("╰")
|
||||
expect(lines[noteRow + 1]).toContain("╯")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -191,9 +191,29 @@ function renderSelfMessage(
|
||||
}
|
||||
|
||||
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
|
||||
placement.textLines.forEach((line, index) =>
|
||||
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
|
||||
)
|
||||
const width = Math.max(...placement.textLines.map(diagramTextWidth))
|
||||
const left = placement.textX
|
||||
const right = left + width - 1
|
||||
const top = placement.textY - 1
|
||||
const bottom = placement.textY + placement.textLines.length
|
||||
|
||||
for (let x = left + 1; x < right; x++) {
|
||||
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
|
||||
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
|
||||
}
|
||||
for (let y = top + 1; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
|
||||
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
|
||||
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
|
||||
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
|
||||
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
|
||||
for (let y = placement.textY; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
@@ -216,12 +236,22 @@ export function drawSequenceDiagramGrid(
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
} else {
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
|
||||
)
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
for (let y = participantHeaderY; y < participantRuleY; y++) {
|
||||
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
}
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ ${Array.from(
|
||||
expect(explicit.activations).toEqual(shorthand.activations)
|
||||
})
|
||||
|
||||
test("left-aligns message label blocks inside their arrow span", () => {
|
||||
test("centers message label blocks over their arrow span", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
@@ -214,6 +214,8 @@ ${Array.from(
|
||||
A->>B: short<br/>a much longer line`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
expect(message.labelX).toBe(message.leftX + 2)
|
||||
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
|
||||
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
const width = labelLinesWidth(mermaidLabelLines(label))
|
||||
return compact ? width : Math.max(3, width)
|
||||
return compact ? width : Math.max(5, width + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
@@ -247,7 +247,7 @@ function getStepContentBounds(
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const labelWidth = messageWidth(step.message)
|
||||
const labelLeftX = leftX + 2
|
||||
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
|
||||
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
|
||||
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
|
||||
)
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
|
||||
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
|
||||
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
|
||||
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SkillApi } from "@opencode-ai/client/effect/api"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Effect, Types } from "effect"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface SkillDraft {
|
||||
list(): readonly Types.DeepMutable<Skill.Info>[]
|
||||
add(skill: Skill.Info): void
|
||||
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
|
||||
remove(id: string): void
|
||||
source(source: Skill.Source): void
|
||||
list(): readonly Skill.Source[]
|
||||
}
|
||||
|
||||
export interface SkillDomain extends SkillApi<unknown> {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { SkillApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Transform } from "./registration.js"
|
||||
import type { DeepMutable } from "./types.js"
|
||||
|
||||
export interface SkillDraft {
|
||||
list(): readonly DeepMutable<Skill.Info>[]
|
||||
add(skill: Skill.Info): void
|
||||
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
|
||||
remove(id: string): void
|
||||
source(source: Skill.Source): void
|
||||
list(): readonly Skill.Source[]
|
||||
}
|
||||
|
||||
export interface SkillDomain extends SkillApi {
|
||||
|
||||
@@ -46,7 +46,6 @@ import type { ServerOptions } from "./options"
|
||||
import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
Global.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
|
||||
rule(["prompt"], theme.hue.accent[step]),
|
||||
rule(["extmark.file"], feedback.warning.default, { bold: true }),
|
||||
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
|
||||
rule(["extmark.skill"], (theme.categorical[1] ?? theme.categorical[0])[step], { bold: true }),
|
||||
rule(["extmark.skill"], theme.categorical[1][step], { bold: true }),
|
||||
// V1 migration preserves its selected/inverse foreground in this action state.
|
||||
rule(["extmark.paste"], theme.text.action.primary.focused, {
|
||||
background: feedback.warning.default,
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
|
||||
+15
-24
@@ -70,9 +70,9 @@ import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { sessionTabsFitVertically } from "./ui/layout"
|
||||
import { effectiveSessionTabPosition } from "./ui/layout"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./prompt/history"
|
||||
@@ -97,7 +97,6 @@ import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider } from "./context/storage"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
@@ -252,13 +251,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
)
|
||||
})
|
||||
const clipboard = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => createTuiClipboard(renderer)),
|
||||
(clipboard) =>
|
||||
Effect.tryPromise(() => clipboard.dispose()).pipe(
|
||||
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
|
||||
),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const finalizers = new Set<() => Promise<void>>()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -295,11 +287,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<TuiAppProvider value={input.app}>
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ErrorComponent error={error} reset={reset} mode={mode} />
|
||||
</ClipboardProvider>
|
||||
)}
|
||||
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
|
||||
>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
@@ -348,7 +336,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider value={clipboard}>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
@@ -372,10 +360,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
@@ -519,7 +504,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
await clipboard
|
||||
.write(text)
|
||||
.write?.(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
@@ -528,7 +513,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
||||
const tabPosition = () => effectiveSessionTabPosition(config.data.tabs.position, dimensions().width)
|
||||
const tabsVisible = () =>
|
||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||
|
||||
@@ -1213,13 +1198,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<Show when={tabsVisible() && tabsVertical()}>
|
||||
<Show when={tabsVisible() && tabPosition() === "left"}>
|
||||
<SessionTabs orientation="vertical" />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show when={tabsVisible() && !tabsVertical()}>
|
||||
<Show when={tabsVisible() && tabPosition() === "top"}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
@@ -1239,10 +1224,16 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={tabsVisible() && tabPosition() === "bottom"}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
</box>
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={tabsVisible() && tabPosition() === "right"}>
|
||||
<SessionTabs orientation="vertical" />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={devtools()}>
|
||||
<DevToolsBar />
|
||||
|
||||
+118
-59
@@ -1,66 +1,125 @@
|
||||
import {
|
||||
createClipboard,
|
||||
createHostClipboard,
|
||||
createRendererClipboardAdapter,
|
||||
decodePasteBytes,
|
||||
type ClipboardService as CoreClipboardService,
|
||||
type RendererClipboardBoundary,
|
||||
} from "@opentui/core"
|
||||
import type { ClipboardContent, ClipboardService } from "./context/clipboard"
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { platform, release, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
export type OwnedClipboardService = Required<ClipboardService> & Readonly<{ dispose(): Promise<void> }>
|
||||
const exec = promisify(execFile)
|
||||
|
||||
export function createTuiClipboard(renderer: RendererClipboardBoundary): OwnedClipboardService {
|
||||
return createClipboardAdapter(
|
||||
createClipboard({
|
||||
host: createHostClipboard(),
|
||||
terminal: createRendererClipboardAdapter(renderer),
|
||||
}),
|
||||
)
|
||||
function command(command: string, args: string[] = [], input?: string) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
|
||||
const output: Buffer[] = []
|
||||
child.on("error", reject)
|
||||
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) return resolve(Buffer.concat(output))
|
||||
reject(new Error(`${command} exited with code ${code}`))
|
||||
})
|
||||
if (input !== undefined) child.stdin?.end(input)
|
||||
})
|
||||
}
|
||||
|
||||
export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedClipboardService {
|
||||
return {
|
||||
async read(): Promise<ClipboardContent | undefined> {
|
||||
const result = await clipboard.read({
|
||||
preferredTypes: ["image/png", "text/plain"],
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.status !== "read") {
|
||||
if (result.status === "failed") throw result.error
|
||||
if (result.status === "timed-out") throw new Error("Clipboard read timed out")
|
||||
if (result.status === "limit-exceeded") {
|
||||
throw new RangeError("Clipboard content exceeded configured read or image conversion limits")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
function writeOsc52(text: string) {
|
||||
if (!process.stdout.isTTY) return
|
||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||
const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\`
|
||||
process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence)
|
||||
}
|
||||
|
||||
if (result.representation.mimeType === "image/png") {
|
||||
return {
|
||||
data: Buffer.from(result.representation.bytes).toString("base64"),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
if (result.representation.mimeType === "text/plain") {
|
||||
if (result.representation.bytes.length === 0) return undefined
|
||||
return {
|
||||
data: decodePasteBytes(result.representation.bytes),
|
||||
mime: result.representation.mimeType,
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
|
||||
},
|
||||
async write(text) {
|
||||
const result = await clipboard.writeText(text, {
|
||||
destination: "all-available",
|
||||
selection: "clipboard",
|
||||
})
|
||||
if (result.host.status === "written" || result.terminal.status === "attempted") return
|
||||
if (result.host.status === "failed") throw result.host.error
|
||||
throw new Error(`Clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`)
|
||||
},
|
||||
dispose() {
|
||||
return clipboard.dispose()
|
||||
},
|
||||
export async function read() {
|
||||
if (platform() === "darwin") {
|
||||
const file = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await exec("osascript", [
|
||||
"-e",
|
||||
'set imageData to the clipboard as "PNGf"',
|
||||
"-e",
|
||||
`set fileRef to open for access POSIX file "${file}" with write permission`,
|
||||
"-e",
|
||||
"set eof fileRef to 0",
|
||||
"-e",
|
||||
"write imageData to fileRef",
|
||||
"-e",
|
||||
"close access fileRef",
|
||||
])
|
||||
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
// Fall through to text clipboard.
|
||||
} finally {
|
||||
await rm(file, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (platform() === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
|
||||
}
|
||||
|
||||
if (platform() === "linux") {
|
||||
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
|
||||
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
|
||||
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
const text = await clipboardy.read().catch(() => undefined)
|
||||
if (text) return { data: text, mime: "text/plain" }
|
||||
}
|
||||
|
||||
export function copyCommand(
|
||||
os: NodeJS.Platform,
|
||||
wayland: boolean,
|
||||
has: (name: string) => boolean,
|
||||
): string[] | undefined {
|
||||
if (os === "darwin" && has("osascript")) return ["osascript"]
|
||||
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
|
||||
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
|
||||
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
|
||||
if (os === "win32" && has("powershell.exe")) {
|
||||
return [
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
|
||||
|
||||
function getCopyMethod() {
|
||||
return (copyMethod ??= (async () => {
|
||||
const { which } = await import("@opencode-ai/core/util/which")
|
||||
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
|
||||
if (native?.[0] === "osascript") {
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
if (native) {
|
||||
return async (text: string) => {
|
||||
await command(native[0], native.slice(1), text).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return async (text: string) => {
|
||||
const { default: clipboardy } = await import("clipboardy")
|
||||
await clipboardy.write(text).catch(() => undefined)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
export async function write(text: string) {
|
||||
writeOsc52(text)
|
||||
const method = await getCopyMethod()
|
||||
await method(text)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { TabPosition, useConfig } from "../config"
|
||||
import { useThemes } from "../context/theme"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -84,15 +84,6 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Transcript images",
|
||||
category: "Session",
|
||||
path: ["session", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
@@ -110,12 +101,12 @@ export const settings: Setting[] = [
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
title: "Position",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "layout"],
|
||||
default: "horizontal",
|
||||
values: ["horizontal", "vertical"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
path: ["tabs", "position"],
|
||||
default: "top",
|
||||
values: TabPosition.literals,
|
||||
keywords: ["sidebar", "orientation", "layout"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
@@ -197,15 +188,6 @@ export const settings: Setting[] = [
|
||||
values: ["compact", "full"],
|
||||
keywords: ["paste summary", "clipboard", "pasted content"],
|
||||
},
|
||||
{
|
||||
title: "Image previews",
|
||||
category: "Input",
|
||||
path: ["prompt", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "clipboard", "images", "prompt"],
|
||||
},
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DialogDebug() {
|
||||
.map((entry) => `${entry.label}: ${entry.value}`)
|
||||
.join("\n")
|
||||
void clipboard
|
||||
.write(text)
|
||||
.write?.(text)
|
||||
.then(() => {
|
||||
setCopied(true)
|
||||
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
type ImagePreviewItem = Readonly<{
|
||||
uri: string
|
||||
mention?: Readonly<{ text: string }>
|
||||
}>
|
||||
|
||||
export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) {
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial)))
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
const current = createMemo(() => props.images[index()])
|
||||
const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8))
|
||||
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
|
||||
function move(direction: number) {
|
||||
if (props.images.length < 2) return
|
||||
setFailed(false)
|
||||
setIndex((value) => (value + direction + props.images.length) % props.images.length)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) },
|
||||
{ bind: "right", title: "Next image", group: "Dialog", run: () => move(1) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Image {index() + 1} of {props.images.length}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<image
|
||||
id="prompt-image-viewer-image"
|
||||
source={current().uri}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height={imageHeight()}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
|
||||
{props.images.length > 1 ? "← previous" : ""}
|
||||
</text>
|
||||
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
|
||||
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
|
||||
{props.images.length > 1 ? "next →" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -492,7 +492,7 @@ function OAuthAuto(props: {
|
||||
run: () => {
|
||||
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
|
||||
clipboard
|
||||
.write(value)
|
||||
.write?.(value)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
|
||||
@@ -172,6 +172,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
const copy = () => {
|
||||
if (!clipboard.write) return
|
||||
void clipboard
|
||||
.write(error())
|
||||
.then(() => setCopied(true))
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
const exit = useExit()
|
||||
const clipboard = useClipboard()
|
||||
const app = useTuiApp()
|
||||
const [copyState, setCopyState] = createSignal<"idle" | "copied" | "failed">("idle")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
// Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
|
||||
// theme context may be the thing that crashed.
|
||||
@@ -46,19 +46,11 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
const issueURL = buildIssueURL(message, stack, app.version)
|
||||
|
||||
const copyReport = () => {
|
||||
void clipboard
|
||||
.write(issueURL.toString())
|
||||
.then(() => setCopyState("copied"))
|
||||
.catch(() => setCopyState("failed"))
|
||||
void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: "c",
|
||||
label: () => ({ idle: "Copy report", copied: "✓ Copied", failed: "Copy failed" })[copyState()],
|
||||
copy: true,
|
||||
onUse: copyReport,
|
||||
},
|
||||
{ key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport },
|
||||
{ key: "r", label: () => "Restart", onUse: props.reset },
|
||||
{ key: "q", label: () => "Quit", onUse: () => exit() },
|
||||
]
|
||||
@@ -143,20 +135,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<For each={actions}>
|
||||
{(action, index) => {
|
||||
const isSelected = () => selected() === index()
|
||||
const copyColor = () =>
|
||||
action.copy
|
||||
? copyState() === "copied"
|
||||
? colors.success
|
||||
: copyState() === "failed"
|
||||
? colors.error
|
||||
: undefined
|
||||
: undefined
|
||||
const isCopied = () => action.copy && copied()
|
||||
return (
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
onMouseDown={() => setSelected(index())}
|
||||
onMouseUp={() => action.onUse()}
|
||||
backgroundColor={copyColor() ?? (isSelected() ? colors.primary : colors.element)}
|
||||
backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element}
|
||||
minWidth={15}
|
||||
alignItems="center"
|
||||
paddingLeft={2}
|
||||
@@ -164,7 +149,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={copyColor() || isSelected() ? colors.onPrimary : colors.text}
|
||||
fg={isCopied() || isSelected() ? colors.onPrimary : colors.text}
|
||||
>
|
||||
{action.label()}
|
||||
</text>
|
||||
@@ -204,11 +189,9 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
<Show when={showFooter()}>
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<text fg={colors.muted}>
|
||||
{copyState() === "copied"
|
||||
{copied()
|
||||
? "Report copied — paste it into a new GitHub issue."
|
||||
: copyState() === "failed"
|
||||
? "Clipboard write failed. Try again or report the crash manually."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
</text>
|
||||
<text fg={colors.muted}>OpenCode {app.version}</text>
|
||||
</box>
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
decodePasteBytes,
|
||||
type KeyEvent,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js"
|
||||
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
@@ -47,20 +48,13 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import {
|
||||
normalizePastedFilepath,
|
||||
parsePastedFilepaths,
|
||||
readLocalAttachment,
|
||||
MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -78,6 +72,17 @@ export type PromptProps = {
|
||||
}
|
||||
}
|
||||
|
||||
function pastedFilepath(value: string, platform: string) {
|
||||
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
}
|
||||
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
current: PromptInfo
|
||||
@@ -307,41 +312,6 @@ export function Prompt(props: PromptProps) {
|
||||
extmarkToPart: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
let disposed = false
|
||||
let pasteQueue = Promise.resolve()
|
||||
|
||||
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || input.isDestroyed) return
|
||||
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||
await run(
|
||||
() =>
|
||||
disposed ||
|
||||
input.isDestroyed ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!disposed) toast.error(error)
|
||||
})
|
||||
return pasteQueue
|
||||
}
|
||||
|
||||
const imageAttachments = createMemo(() =>
|
||||
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||
)
|
||||
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
|
||||
|
||||
function openImagePreview(initial: number) {
|
||||
const images = imageAttachments()
|
||||
if (images.length === 0) return
|
||||
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -421,32 +391,22 @@ export function Prompt(props: PromptProps) {
|
||||
name: "prompt.paste",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
return enqueuePaste(async (changed) => {
|
||||
const content = await clipboard.read()
|
||||
if (changed()) return
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data, changed)
|
||||
}
|
||||
})
|
||||
const content = await clipboard.read?.()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
filename: "clipboard",
|
||||
uri: `data:${content.mime};base64,${content.data}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View image attachments",
|
||||
name: "prompt.images.view",
|
||||
category: "Prompt",
|
||||
enabled: imageAttachments().length > 0,
|
||||
run: () => openImagePreview(0),
|
||||
},
|
||||
{
|
||||
title: "Interrupt session",
|
||||
name: "session.interrupt",
|
||||
@@ -601,7 +561,6 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
"prompt.images.view",
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
@@ -655,7 +614,6 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
@@ -1304,39 +1262,27 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string, changed: () => boolean) {
|
||||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
const attachment = await readLocalAttachment(filepath)
|
||||
if (attachment) {
|
||||
if (changed()) return
|
||||
pasteLocalAttachment(filepath, attachment)
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment?.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
|
||||
const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
|
||||
if (filepaths.length > 1) {
|
||||
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
|
||||
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
|
||||
for (const candidate of filepaths) {
|
||||
const next = await readLocalAttachment(candidate, remaining)
|
||||
if (!next) break
|
||||
remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength
|
||||
attachments.push({ filepath: candidate, attachment: next })
|
||||
}
|
||||
if (attachments.length === filepaths.length) {
|
||||
if (changed()) return
|
||||
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
|
||||
return
|
||||
}
|
||||
if (attachment?.type === "binary") {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (changed()) return
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
|
||||
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
|
||||
@@ -1361,27 +1307,12 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
|
||||
const filename = path.basename(filepath)
|
||||
if (attachment.type === "text") {
|
||||
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
|
||||
return
|
||||
}
|
||||
pasteAttachment({
|
||||
filename,
|
||||
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
|
||||
})
|
||||
}
|
||||
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const prefix = pdf ? "data:application/pdf;" : "data:image/"
|
||||
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
@@ -1413,6 +1344,7 @@ export function Prompt(props: PromptProps) {
|
||||
draft.extmarkToPart.set(extmarkId, { type: "file", index })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
@@ -1536,74 +1468,6 @@ export function Prompt(props: PromptProps) {
|
||||
flexGrow={1}
|
||||
width="100%"
|
||||
>
|
||||
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
|
||||
<box
|
||||
width="100%"
|
||||
height={imagePreviewHeight() + 1}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
justifyContent="flex-start"
|
||||
gap={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<For each={visibleImageAttachments()}>
|
||||
{(file, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={imagePreviewWidth()}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={imagePreviewWidth()}
|
||||
flexShrink={1}
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
openImagePreview(index())
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!failed()}
|
||||
fallback={
|
||||
<box width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||
<text fg={theme.text.subdued}>No preview</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<image
|
||||
id={`prompt-image-preview-${index()}`}
|
||||
source={file.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={imageAttachments().length > visibleImageAttachments().length}>
|
||||
<box
|
||||
width={8}
|
||||
height={imagePreviewHeight()}
|
||||
flexBasis={8}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
openImagePreview(visibleImageAttachments().length)
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate>
|
||||
+{imageAttachments().length - visibleImageAttachments().length} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<textarea
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
@@ -1632,7 +1496,7 @@ export function Prompt(props: PromptProps) {
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={(event: PasteEvent) => {
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1642,10 +1506,11 @@ export function Prompt(props: PromptProps) {
|
||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||
// Replace CRLF first, then any remaining CR
|
||||
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
|
||||
// Windows Terminal <1.25 can surface image-only clipboard as an
|
||||
// empty bracketed paste. Windows Terminal 1.25+ does not.
|
||||
if (event.bytes.byteLength === 0) {
|
||||
if (!pastedContent) {
|
||||
keymap.dispatch("prompt.paste")
|
||||
return
|
||||
}
|
||||
@@ -1654,7 +1519,7 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
|
||||
await pasteInputText(normalizedText)
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
|
||||
const MAX_PASTED_FILEPATHS = 32
|
||||
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export type LocalFiles = Readonly<{
|
||||
readText(path: string, maxBytes: number): Promise<string>
|
||||
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
|
||||
readText(path: string): Promise<string>
|
||||
readBytes(path: string): Promise<Uint8Array>
|
||||
mime(path: string): Promise<string>
|
||||
}>
|
||||
|
||||
@@ -14,15 +11,14 @@ export type LocalAttachment =
|
||||
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
|
||||
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
|
||||
|
||||
export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
|
||||
export function readLocalAttachment(file: string) {
|
||||
return readLocalAttachmentWith(
|
||||
{
|
||||
readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
|
||||
readBytes: readFileBounded,
|
||||
readText: (value) => readFile(value, "utf8"),
|
||||
readBytes: (value) => readFile(value),
|
||||
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
|
||||
},
|
||||
file,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,108 +33,16 @@ const mimeTypes: Record<string, string> = {
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
async function readFileBounded(file: string, maxBytes: number) {
|
||||
const source = Bun.file(file)
|
||||
if (!(await source.exists())) throw new Error("Attachment does not exist")
|
||||
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
|
||||
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
|
||||
return content
|
||||
}
|
||||
|
||||
export function normalizePastedFilepath(value: string, platform: string) {
|
||||
const raw = value.replace(/^['"]+|['"]+$/g, "")
|
||||
const url = decodeFileURL(raw, platform)
|
||||
if (url) return url
|
||||
if (platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
}
|
||||
|
||||
function decodeFileURL(value: string, platform: string): string | undefined {
|
||||
if (!value.startsWith("file://")) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (/%2f|%5c/i.test(url.pathname)) return undefined
|
||||
const pathname = decodeURIComponent(url.pathname)
|
||||
if (platform !== "win32") {
|
||||
if (url.hostname && url.hostname !== "localhost") return undefined
|
||||
return pathname
|
||||
}
|
||||
const local = pathname.replace(/^\/([A-Za-z]:)/, "$1").replaceAll("/", "\\")
|
||||
if (url.hostname && url.hostname !== "localhost") return `\\\\${url.hostname}${local}`
|
||||
return local
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePastedFilepaths(value: string, platform: string) {
|
||||
const result: string[] = []
|
||||
let current = ""
|
||||
let quote = ""
|
||||
|
||||
function push() {
|
||||
if (!current) return
|
||||
result.push(decodeFileURL(current, platform) ?? current)
|
||||
current = ""
|
||||
}
|
||||
|
||||
const input = value.includes("file://")
|
||||
? value
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trimStart().startsWith("#"))
|
||||
.join("\n")
|
||||
: value
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const character = input[index]
|
||||
if (quote) {
|
||||
if (character === quote) {
|
||||
quote = ""
|
||||
continue
|
||||
}
|
||||
if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) {
|
||||
current += input[++index]
|
||||
continue
|
||||
}
|
||||
current += character
|
||||
continue
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
|
||||
current += input[++index]
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(character)) {
|
||||
push()
|
||||
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||
continue
|
||||
}
|
||||
current += character
|
||||
}
|
||||
|
||||
if (quote) return []
|
||||
push()
|
||||
if (result.length > MAX_PASTED_FILEPATHS) return []
|
||||
return result
|
||||
}
|
||||
|
||||
export async function readLocalAttachmentWith(
|
||||
files: LocalFiles,
|
||||
path: string,
|
||||
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
|
||||
): Promise<LocalAttachment | undefined> {
|
||||
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
|
||||
const mime = await files.mime(path).catch(() => undefined)
|
||||
if (!mime) return undefined
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined
|
||||
if (!mime) return
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await files.readText(path, maxBytes).catch(() => undefined)
|
||||
if (!content || Buffer.byteLength(content) > maxBytes) return undefined
|
||||
const content = await files.readText(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "text", mime, content }
|
||||
}
|
||||
const content = await files.readBytes(path, maxBytes).catch(() => undefined)
|
||||
if (!content || content.byteLength > maxBytes) return undefined
|
||||
if (!mime.startsWith("image/") && mime !== "application/pdf") return
|
||||
const content = await files.readBytes(path).catch(() => undefined)
|
||||
if (!content) return
|
||||
return { type: "binary", mime, content }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
@@ -16,19 +16,16 @@ import {
|
||||
type SessionTab,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
import { marqueeText } from "../util/marquee"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
@@ -49,41 +46,6 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: number, leading: number) {
|
||||
const fade = (position: number) => (position <= 0 ? 0 : 0.2 + 0.72 * ((position - 1) / Math.max(1, FADE_WIDTH - 1)))
|
||||
const start = index < FADE_WIDTH ? FADE_WIDTH - index : 0
|
||||
const end = index - (length - FADE_WIDTH) + 1
|
||||
const opacity = Math.max(fade(start) * leading, fade(end))
|
||||
return opacity === 0 ? color : tint(color, background, opacity)
|
||||
}
|
||||
|
||||
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
|
||||
createEffect(() => {
|
||||
if (!hovered()) {
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
return
|
||||
}
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
const delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
leading.animate({ opacity: 1 })
|
||||
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
|
||||
}, MARQUEE_DELAY)
|
||||
onCleanup(() => {
|
||||
clearTimeout(delay)
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
})
|
||||
|
||||
return { offset, leading: () => leading.value().opacity }
|
||||
}
|
||||
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
@@ -107,7 +69,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
@@ -183,12 +144,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const numberWidth = () => 2
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), titleWidth(), marquee.offset())
|
||||
: Locale.takeWidth(title(), titleWidth()),
|
||||
)
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
@@ -250,15 +206,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
pulseBackground(),
|
||||
index,
|
||||
visibleTitleParts().length,
|
||||
scrolling() ? marquee.leading() : 0,
|
||||
)
|
||||
: color
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
@@ -275,10 +225,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
@@ -435,7 +382,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
@@ -615,12 +561,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
||||
: Locale.takeWidth(title(), availableTitleWidth()),
|
||||
)
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
@@ -634,15 +575,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
background(),
|
||||
index,
|
||||
visibleTitleParts().length,
|
||||
scrolling() ? marquee.leading() : 0,
|
||||
)
|
||||
: color
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
@@ -676,10 +611,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
|
||||
@@ -44,6 +44,9 @@ export const Cursor = Schema.Struct({
|
||||
}),
|
||||
}).annotate({ description: "Terminal cursor settings" })
|
||||
|
||||
export const TabPosition = Schema.Literals(["top", "bottom", "left", "right"])
|
||||
export type TabPosition = Schema.Schema.Type<typeof TabPosition>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
theme: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -114,9 +117,6 @@ export const Info = Schema.Struct({
|
||||
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
|
||||
description: "Display large pastes as compact placeholders or full text",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show image attachment previews above the prompt input",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Prompt input behavior" }),
|
||||
session: Schema.optional(
|
||||
@@ -131,9 +131,6 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
@@ -147,8 +144,8 @@ export const Info = Schema.Struct({
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||
position: Schema.optional(TabPosition).annotate({
|
||||
description: "Show tabs along the top, bottom, left, or right edge",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
@@ -214,7 +211,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
tabs: {
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
layout: "horizontal" | "vertical"
|
||||
position: TabPosition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +253,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
...input.tabs,
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
scope: input.tabs?.scope ?? "cwd",
|
||||
layout: input.tabs?.layout ?? "horizontal",
|
||||
position: input.tabs?.position ?? "top",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,6 @@ export const Definitions = {
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_images_view: keybind("<leader>i", "View image attachments"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||
@@ -367,7 +366,6 @@ export const CommandMap = {
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_queue: "prompt.queue",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_images_view: "prompt.images.view",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
prompt_stash_pop: "prompt.stash.pop",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardService = Readonly<{
|
||||
read(): Promise<ClipboardContent | undefined>
|
||||
write(text: string): Promise<void>
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
const ClipboardContext = createContext<ClipboardService>()
|
||||
|
||||
export function ClipboardProvider(props: { value: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value}>{props.children}</ClipboardContext.Provider>
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
const value = useContext(ClipboardContext)
|
||||
if (!value) throw new Error("useClipboard must be used within a ClipboardProvider")
|
||||
return value
|
||||
return useContext(ClipboardContext)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!m) return
|
||||
if (route.data.type === "session") {
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
return
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
savePreferences()
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
@@ -68,15 +69,15 @@ export type ThemeSource = Readonly<{
|
||||
subscribeRefresh?(refresh: () => void): () => void
|
||||
}>
|
||||
|
||||
export const createThemeSource = (config: string): ThemeSource => ({
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
return discoverThemes(configDirectories(config, process.cwd()))
|
||||
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
return () => process.off("SIGUSR2", refresh)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { discoverThemes } from "../theme/discovery"
|
||||
|
||||
@@ -138,11 +139,11 @@ subscribeThemes((themes) => setStore("themes", themes))
|
||||
|
||||
const themeContext = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
|
||||
const renderer = useRenderer()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user