mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-17 04:04:27 +00:00
Compare commits
26 Commits
debug/prom
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14e874e51 | ||
|
|
544315dff7 | ||
|
|
f13da808ff | ||
|
|
e416e59ea6 | ||
|
|
cb69501098 | ||
|
|
a64f604d54 | ||
|
|
d7093abf61 | ||
|
|
60af447908 | ||
|
|
1cdc558ac0 | ||
|
|
3849822769 | ||
|
|
e9a17e4480 | ||
|
|
68809365df | ||
|
|
8da511dfa8 | ||
|
|
69381f6aea | ||
|
|
df6508530f | ||
|
|
335356280c | ||
|
|
03d84f49c2 | ||
|
|
2cbdf04ec9 | ||
|
|
410fbd8a00 | ||
|
|
e5cbecf17c | ||
|
|
ca3af5dc6a | ||
|
|
9e740d9947 | ||
|
|
d4694d058c | ||
|
|
469c3a4204 | ||
|
|
4cb29967f6 | ||
|
|
e718db624f |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -21,3 +21,4 @@ r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCode2026
|
||||
|
||||
29
bun.lock
29
bun.lock
@@ -324,6 +324,7 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "4.0.0-beta.31",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -972,6 +973,10 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
@@ -1168,6 +1173,8 @@
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
||||
|
||||
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
||||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
|
||||
@@ -2536,6 +2543,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||
|
||||
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||
@@ -3176,6 +3185,8 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -3408,10 +3419,14 @@
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||
@@ -3598,7 +3613,7 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -4022,6 +4037,10 @@
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
@@ -4280,6 +4299,8 @@
|
||||
|
||||
"stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -4986,12 +5007,16 @@
|
||||
|
||||
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -5032,6 +5057,8 @@
|
||||
|
||||
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
|
||||
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
|
||||
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
|
||||
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
|
||||
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
|
||||
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
|
||||
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
|
||||
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,8 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||
|
||||
### Wait on state
|
||||
@@ -182,6 +184,9 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Avoid race-prone flows that assume work is finished after an action
|
||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||
- Do not treat a visible element as proof that the app will route the next action to it
|
||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||
|
||||
### Add hooks
|
||||
|
||||
@@ -189,11 +194,16 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||
|
||||
### Prefer helpers
|
||||
|
||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||
- Prefer helpers that both perform an action and verify the app consumed it
|
||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
promptSelector,
|
||||
terminalSelector,
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
@@ -61,6 +62,15 @@ async function terminalReady(page: Page, term?: Locator) {
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||
const next = term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||
return (state?.focusing ?? 0) === 0
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||
const next = input.term ?? page.locator(terminalSelector).first()
|
||||
const id = await terminalID(next)
|
||||
@@ -73,6 +83,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string })
|
||||
)
|
||||
}
|
||||
|
||||
async function promptSlashActive(page: Page, id: string) {
|
||||
return page.evaluate((id) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (state?.popover !== "slash") return false
|
||||
if (!state.slash.ids.includes(id)) return false
|
||||
return state.slash.active === id
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function promptSlashSelects(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||
return page.evaluate((input) => {
|
||||
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||
if (!state) return false
|
||||
return state.selected === input.id && state.selects >= input.count
|
||||
}, input)
|
||||
}
|
||||
|
||||
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
@@ -81,6 +114,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti
|
||||
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input?.timeout ?? 10_000
|
||||
await waitTerminalReady(page, { term, timeout })
|
||||
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function showPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill(input.text).catch(() => false)
|
||||
return promptSlashActive(page, input.id).catch(() => false)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function runPromptSlash(
|
||||
page: Page,
|
||||
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||
) {
|
||||
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||
const timeout = input.timeout ?? 10_000
|
||||
const count = await promptSlashSelects(page)
|
||||
await showPromptSlash(page, input)
|
||||
await prompt.press("Enter")
|
||||
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||
const term = input.term ?? page.locator(terminalSelector).first()
|
||||
const timeout = input.timeout ?? 10_000
|
||||
|
||||
@@ -98,6 +98,9 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
||||
model: {
|
||||
enabled: true,
|
||||
},
|
||||
prompt: {
|
||||
enabled: true,
|
||||
},
|
||||
terminal: {
|
||||
enabled: true,
|
||||
terminals: {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
|
||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
@@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
|
||||
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await prompt.fill("/terminal")
|
||||
await expect(slash).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
|
||||
// which can steal focus from the prompt and prevent fill() from triggering
|
||||
// the slash popover. Re-attempt click+fill until all retries are exhausted
|
||||
// and the popover appears.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await prompt.click().catch(() => false)
|
||||
await prompt.fill("/terminal").catch(() => false)
|
||||
return slash.isVisible().catch(() => false)
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
await page.keyboard.press("Enter")
|
||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||
await expect(terminal).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
|
||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await waitTerminalReady(page, { term: terminal })
|
||||
await waitTerminalFocusIdle(page, { term: terminal })
|
||||
|
||||
await page.keyboard.press(`${modKey}+Y`)
|
||||
await expect(terminal).not.toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { waitTerminalReady } from "../actions"
|
||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||
import { promptSelector, terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey } from "../utils"
|
||||
|
||||
@@ -14,7 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
}
|
||||
|
||||
await waitTerminalReady(page, { term: terminals.first() })
|
||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||
await expect(terminals).toHaveCount(1)
|
||||
|
||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
@@ -604,6 +605,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||
if (!cmd) return
|
||||
promptProbe.select(cmd.id)
|
||||
closePopover()
|
||||
|
||||
if (cmd.type === "custom") {
|
||||
@@ -692,6 +694,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
if (promptEnabled()) {
|
||||
createEffect(() => {
|
||||
promptProbe.set({
|
||||
popover: store.popover,
|
||||
slash: {
|
||||
active: slashActive() ?? null,
|
||||
ids: slashFlat().map((cmd) => cmd.id),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => promptProbe.clear())
|
||||
}
|
||||
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
|
||||
@@ -18,8 +18,10 @@ import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { terminalProbe } from "@/testing/terminal"
|
||||
|
||||
export function TerminalPanel() {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
@@ -79,16 +81,20 @@ export function TerminalPanel() {
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
const probe = terminalProbe(id)
|
||||
probe.focus(delays.length + 1)
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
probe.step()
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = [120, 240].map((ms) =>
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
probe.step()
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
@@ -96,6 +102,7 @@ export function TerminalPanel() {
|
||||
)
|
||||
|
||||
return () => {
|
||||
probe.focus(0)
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
|
||||
56
packages/app/src/testing/prompt.ts
Normal file
56
packages/app/src/testing/prompt.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { E2EWindow } from "./terminal"
|
||||
|
||||
export type PromptProbeState = {
|
||||
popover: "at" | "slash" | null
|
||||
slash: {
|
||||
active: string | null
|
||||
ids: string[]
|
||||
}
|
||||
selected: string | null
|
||||
selects: number
|
||||
}
|
||||
|
||||
export const promptEnabled = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||
}
|
||||
|
||||
const root = () => {
|
||||
if (!promptEnabled()) return
|
||||
return (window as E2EWindow).__opencode_e2e?.prompt
|
||||
}
|
||||
|
||||
export const promptProbe = {
|
||||
set(input: Omit<PromptProbeState, "selected" | "selects">) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
state.current = {
|
||||
popover: input.popover,
|
||||
slash: {
|
||||
active: input.slash.active,
|
||||
ids: [...input.slash.ids],
|
||||
},
|
||||
selected: state.current?.selected ?? null,
|
||||
selects: state.current?.selects ?? 0,
|
||||
}
|
||||
},
|
||||
select(id: string) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
const prev = state.current
|
||||
state.current = {
|
||||
popover: prev?.popover ?? null,
|
||||
slash: {
|
||||
active: prev?.slash.active ?? null,
|
||||
ids: [...(prev?.slash.ids ?? [])],
|
||||
},
|
||||
selected: id,
|
||||
selects: (prev?.selects ?? 0) + 1,
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
state.current = undefined
|
||||
},
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type TerminalProbeState = {
|
||||
connects: number
|
||||
rendered: string
|
||||
settled: number
|
||||
focusing: number
|
||||
}
|
||||
|
||||
type TerminalProbeControl = {
|
||||
@@ -19,6 +20,10 @@ export type E2EWindow = Window & {
|
||||
enabled?: boolean
|
||||
current?: ModelProbeState
|
||||
}
|
||||
prompt?: {
|
||||
enabled?: boolean
|
||||
current?: import("./prompt").PromptProbeState
|
||||
}
|
||||
terminal?: {
|
||||
enabled?: boolean
|
||||
terminals?: Record<string, TerminalProbeState>
|
||||
@@ -32,6 +37,7 @@ const seed = (): TerminalProbeState => ({
|
||||
connects: 0,
|
||||
rendered: "",
|
||||
settled: 0,
|
||||
focusing: 0,
|
||||
})
|
||||
|
||||
const root = () => {
|
||||
@@ -88,6 +94,15 @@ export const terminalProbe = (id: string) => {
|
||||
const prev = state[id] ?? seed()
|
||||
state[id] = { ...prev, settled: prev.settled + 1 }
|
||||
},
|
||||
focus(count: number) {
|
||||
set({ focusing: Math.max(0, count) })
|
||||
},
|
||||
step() {
|
||||
const state = terms()
|
||||
if (!state) return
|
||||
const prev = state[id] ?? seed()
|
||||
state[id] = { ...prev, focusing: Math.max(0, prev.focusing - 1) }
|
||||
},
|
||||
control(next: Partial<TerminalProbeControl>) {
|
||||
const state = controls()
|
||||
if (!state) return
|
||||
|
||||
@@ -34,6 +34,7 @@ Instructions to follow when writing Effect.
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
|
||||
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
|
||||
|
||||
## Time
|
||||
|
||||
@@ -42,3 +43,37 @@ Instructions to follow when writing Effect.
|
||||
## Errors
|
||||
|
||||
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Instance-scoped Effect services
|
||||
|
||||
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
|
||||
|
||||
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
|
||||
2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
|
||||
3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
|
||||
4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
|
||||
|
||||
### Instance.bind — ALS context for native callbacks
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
|
||||
|
||||
**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
|
||||
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
|
||||
|
||||
```typescript
|
||||
// Native addon callback — needs Instance.bind
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
|
||||
## Flag → Effect.Config migration
|
||||
|
||||
Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
|
||||
|
||||
- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
|
||||
- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
|
||||
- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
|
||||
- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@effect/platform-node": "4.0.0-beta.31",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -70,7 +70,7 @@ function init() {
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
|
||||
@@ -17,17 +17,21 @@ export namespace Editor {
|
||||
await Filesystem.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
return content || undefined
|
||||
try {
|
||||
const parts = editor.split(" ")
|
||||
const proc = Process.spawn([...parts, filepath], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Filesystem.readText(filepath)
|
||||
return content || undefined
|
||||
} finally {
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1052,12 @@ export namespace Config {
|
||||
})
|
||||
.optional(),
|
||||
plugin: z.string().array().optional(),
|
||||
snapshot: z.boolean().optional(),
|
||||
snapshot: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
),
|
||||
share: z
|
||||
.enum(["manual", "auto", "disabled"])
|
||||
.optional()
|
||||
|
||||
13
packages/opencode/src/effect/instance-context.ts
Normal file
13
packages/opencode/src/effect/instance-context.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ServiceMap } from "effect"
|
||||
import type { Project } from "@/project/project"
|
||||
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||
"opencode/InstanceContext",
|
||||
) {}
|
||||
12
packages/opencode/src/effect/instance-registry.ts
Normal file
12
packages/opencode/src/effect/instance-registry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const disposers = new Set<(directory: string) => Promise<void>>()
|
||||
|
||||
export function registerDisposer(disposer: (directory: string) => Promise<void>) {
|
||||
disposers.add(disposer)
|
||||
return () => {
|
||||
disposers.delete(disposer)
|
||||
}
|
||||
}
|
||||
|
||||
export async function disposeInstance(directory: string) {
|
||||
await Promise.allSettled([...disposers].map((disposer) => disposer(directory)))
|
||||
}
|
||||
64
packages/opencode/src/effect/instances.ts
Normal file
64
packages/opencode/src/effect/instances.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { FileService } from "@/file"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
||||
"opencode/Instances",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
Instances,
|
||||
Effect.gen(function* () {
|
||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||
return Instances.of(layerMap)
|
||||
}),
|
||||
)
|
||||
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
import { AuthService } from "@/auth/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer),
|
||||
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||
)
|
||||
|
||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,115 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export namespace FileTimeService {
|
||||
export interface Service {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
}
|
||||
}
|
||||
|
||||
type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
function stamp(file: string): Stamp {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: new Date(),
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
|
||||
let value = reads.get(sessionID)
|
||||
if (!value) {
|
||||
value = new Map<string, Stamp>()
|
||||
reads.set(sessionID, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
|
||||
"@opencode/FileTime",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileTimeService,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
function getLock(filepath: string) {
|
||||
let lock = locks.get(filepath)
|
||||
if (!lock) {
|
||||
lock = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, lock)
|
||||
}
|
||||
return lock
|
||||
}
|
||||
|
||||
return FileTimeService.of({
|
||||
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, stamp(file))
|
||||
}),
|
||||
|
||||
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
}),
|
||||
|
||||
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const next = stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
|
||||
if (changed) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
const lock = getLock(filepath)
|
||||
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
// Per-session read times plus per-file write locks.
|
||||
// All tools that overwrite existing files should run their
|
||||
// assert/read/write/update sequence inside withLock(filepath, ...)
|
||||
// so concurrent writes to the same file are serialized.
|
||||
export const state = Instance.state(() => {
|
||||
const read: {
|
||||
[sessionID: string]: {
|
||||
[path: string]: Date | undefined
|
||||
}
|
||||
} = {}
|
||||
const locks = new Map<string, Promise<void>>()
|
||||
return {
|
||||
read,
|
||||
locks,
|
||||
}
|
||||
})
|
||||
|
||||
export function read(sessionID: string, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
const { read } = state()
|
||||
read[sessionID] = read[sessionID] || {}
|
||||
read[sessionID][file] = new Date()
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
|
||||
}
|
||||
|
||||
export function get(sessionID: string, file: string) {
|
||||
return state().read[sessionID]?.[file]
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const current = state()
|
||||
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
|
||||
let release: () => void = () => {}
|
||||
const nextLock = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const chained = currentLock.then(() => nextLock)
|
||||
current.locks.set(filepath, chained)
|
||||
await currentLock
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
release()
|
||||
if (current.locks.get(filepath) === chained) {
|
||||
current.locks.delete(filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function assert(sessionID: string, filepath: string) {
|
||||
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
|
||||
return
|
||||
}
|
||||
|
||||
const time = get(sessionID, filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const mtime = Filesystem.stat(filepath)?.mtime
|
||||
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
|
||||
if (mtime && mtime.getTime() > time.getTime() + 50) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
}
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import z from "zod"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Config } from "../config/config"
|
||||
@@ -9,118 +10,139 @@ import path from "path"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { readdir } from "fs/promises"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
export namespace FileWatcher {
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
const event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
log.info("init")
|
||||
const cfg = await Config.get()
|
||||
const backend = (() => {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
})()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { platform: process.platform })
|
||||
return {}
|
||||
}
|
||||
log.info("watcher backend", { platform: process.platform, backend })
|
||||
export namespace FileWatcher {
|
||||
export const Event = event
|
||||
/** Whether the native @parcel/watcher binding is available on this platform. */
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return {}
|
||||
const init = Effect.fn("FileWatcherService.init")(function* () {})
|
||||
|
||||
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
}
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
const pending = w.subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()],
|
||||
backend,
|
||||
})
|
||||
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
|
||||
log.error("failed to subscribe to Instance.directory", { error: err })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return undefined
|
||||
})
|
||||
if (sub) subs.push(sub)
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = await git(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
const pending = w.subscribe(vcsDir, subscribe, {
|
||||
ignore: ignoreList,
|
||||
backend,
|
||||
})
|
||||
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
|
||||
log.error("failed to subscribe to vcsDir", { error: err })
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return undefined
|
||||
})
|
||||
if (sub) subs.push(sub)
|
||||
}
|
||||
}
|
||||
|
||||
return { subs }
|
||||
},
|
||||
async (state) => {
|
||||
if (!state.subs) return
|
||||
await Promise.all(state.subs.map((sub) => sub?.unsubscribe()))
|
||||
},
|
||||
)
|
||||
|
||||
export function init() {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) {
|
||||
return
|
||||
}
|
||||
state()
|
||||
export namespace FileWatcherService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
|
||||
"@opencode/FileWatcher",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileWatcherService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
|
||||
|
||||
log.info("init", { directory: instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||
return FileWatcherService.of({ init })
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return FileWatcherService.of({ init })
|
||||
|
||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
const subscribe = (dir: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||
return Effect.gen(function* () {
|
||||
const sub = yield* Effect.promise(() => pending)
|
||||
subs.push(sub)
|
||||
}).pipe(
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
// Clean up a subscription that resolves after timeout
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()])
|
||||
}
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
cwd: instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
)
|
||||
yield* subscribe(vcsDir, ignore)
|
||||
}
|
||||
}
|
||||
|
||||
return FileWatcherService.of({ init })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(FileWatcherService.of({ init }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Config } from "effect"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "true" || value === "1"
|
||||
@@ -40,8 +42,12 @@ export namespace Flag {
|
||||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
|
||||
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
|
||||
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
|
||||
Config.withDefault(false),
|
||||
)
|
||||
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean(
|
||||
"OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER",
|
||||
).pipe(Config.withDefault(false))
|
||||
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
|
||||
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
|
||||
|
||||
@@ -55,7 +61,9 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(
|
||||
Config.withDefault(false),
|
||||
)
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
|
||||
@@ -9,10 +9,13 @@ import { Config } from "../config/config"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
@@ -24,117 +27,135 @@ export namespace Format {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
if (cfg.formatter === false) {
|
||||
log.info("all formatters are disabled")
|
||||
return {
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
})
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
formatters,
|
||||
}
|
||||
})
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
const s = await state()
|
||||
let status = s.enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
s.enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const formatters = await state().then((x) => x.formatters)
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
export async function init() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const enabled = await isEnabled(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function init() {
|
||||
log.info("init")
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace FormatService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Format.Status[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormatService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
formatters[item.name] = item
|
||||
}
|
||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||
if (item.disabled) {
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
}) as Formatter.Info
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
}
|
||||
|
||||
async function isEnabled(item: Formatter.Info) {
|
||||
let status = enabled[item.name]
|
||||
if (status === undefined) {
|
||||
status = await item.enabled()
|
||||
enabled[item.name] = status
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
async function getFormatter(ext: string) {
|
||||
const result = []
|
||||
for (const item of Object.values(formatters)) {
|
||||
log.info("checking", { name: item.name, ext })
|
||||
if (!item.extensions.includes(ext)) continue
|
||||
if (!(await isEnabled(item))) continue
|
||||
log.info("enabled", { name: item.name, ext })
|
||||
result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
log.info("init")
|
||||
|
||||
const init = Effect.fn("FormatService.init")(function* () {})
|
||||
|
||||
const status = Effect.fn("FormatService.status")(function* () {
|
||||
const result: Format.Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled: isOn,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return FormatService.of({ init, status })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Config } from "@/config/config"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Effect } from "effect"
|
||||
import os from "os"
|
||||
import * as S from "./service"
|
||||
import type {
|
||||
Action as ActionType,
|
||||
PermissionError,
|
||||
Reply as ReplyType,
|
||||
Request as RequestType,
|
||||
Rule as RuleType,
|
||||
Ruleset as RulesetType,
|
||||
} from "./service"
|
||||
|
||||
export namespace PermissionNext {
|
||||
function expand(pattern: string): string {
|
||||
@@ -23,20 +14,16 @@ export namespace PermissionNext {
|
||||
return pattern
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: S.PermissionService.Api) => Effect.Effect<A, PermissionError>) {
|
||||
return runtime.runPromise(S.PermissionService.use(f))
|
||||
}
|
||||
|
||||
export const Action = S.Action
|
||||
export type Action = ActionType
|
||||
export type Action = S.Action
|
||||
export const Rule = S.Rule
|
||||
export type Rule = RuleType
|
||||
export type Rule = S.Rule
|
||||
export const Ruleset = S.Ruleset
|
||||
export type Ruleset = RulesetType
|
||||
export type Ruleset = S.Ruleset
|
||||
export const Request = S.Request
|
||||
export type Request = RequestType
|
||||
export type Request = S.Request
|
||||
export const Reply = S.Reply
|
||||
export type Reply = ReplyType
|
||||
export type Reply = S.Reply
|
||||
export const Approval = S.Approval
|
||||
export const Event = S.Event
|
||||
export const Service = S.PermissionService
|
||||
@@ -66,12 +53,16 @@ export namespace PermissionNext {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input)))
|
||||
export const ask = fn(S.AskInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
|
||||
)
|
||||
|
||||
export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input)))
|
||||
export const reply = fn(S.ReplyInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
|
||||
)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((service) => service.list())
|
||||
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
@@ -104,11 +103,6 @@ interface PendingEntry {
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
type State = {
|
||||
pending: Map<PermissionID, PendingEntry>
|
||||
approved: Ruleset
|
||||
}
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
@@ -133,25 +127,19 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
static readonly layer = Layer.effect(
|
||||
PermissionService,
|
||||
Effect.gen(function* () {
|
||||
const instanceState = yield* InstanceState.make<State>(() =>
|
||||
Effect.sync(() => {
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(),
|
||||
)
|
||||
return {
|
||||
pending: new Map<PermissionID, PendingEntry>(),
|
||||
approved: row?.data ?? [],
|
||||
}
|
||||
}),
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const state = yield* InstanceState.get(instanceState)
|
||||
const { ruleset, ...request } = input
|
||||
let pending = false
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, state.approved)
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
@@ -159,10 +147,10 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
pending = true
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!pending) return
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
@@ -172,22 +160,21 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
state.pending.set(id, { info, deferred })
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
state.pending.delete(id)
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const state = yield* InstanceState.get(instanceState)
|
||||
const existing = state.pending.get(input.requestID)
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
state.pending.delete(input.requestID)
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
@@ -200,9 +187,9 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of state.pending.entries()) {
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
state.pending.delete(id)
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
@@ -217,20 +204,20 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
state.approved.push({
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of state.pending.entries()) {
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow",
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
state.pending.delete(id)
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
@@ -246,8 +233,7 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
|
||||
})
|
||||
|
||||
const list = Effect.fn("PermissionService.list")(function* () {
|
||||
const state = yield* InstanceState.get(instanceState)
|
||||
return Array.from(state.pending.values(), (item) => item.info)
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return PermissionService.of({ ask, reply, list })
|
||||
|
||||
@@ -185,12 +185,10 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const deploymentType = inputs.deploymentType || "github.com"
|
||||
|
||||
let domain = "github.com"
|
||||
let actualProvider = "github-copilot"
|
||||
|
||||
if (deploymentType === "enterprise") {
|
||||
const enterpriseUrl = inputs.enterpriseUrl
|
||||
domain = normalizeDomain(enterpriseUrl!)
|
||||
actualProvider = "github-copilot-enterprise"
|
||||
}
|
||||
|
||||
const urls = getUrls(domain)
|
||||
@@ -262,8 +260,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
expires: 0,
|
||||
}
|
||||
|
||||
if (actualProvider === "github-copilot-enterprise") {
|
||||
result.provider = "github-copilot-enterprise"
|
||||
if (deploymentType === "enterprise") {
|
||||
result.enterpriseUrl = domain
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { FileWatcherService } from "../file/watcher"
|
||||
import { File } from "../file"
|
||||
import { Project } from "./project"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Vcs } from "./vcs"
|
||||
import { VcsService } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
Format.init()
|
||||
await Format.init()
|
||||
await LSP.init()
|
||||
FileWatcher.init()
|
||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||
File.init()
|
||||
Vcs.init()
|
||||
await runPromiseInstance(VcsService.use((s) => s.init()))
|
||||
Snapshot.init()
|
||||
Truncate.init()
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Effect } from "effect"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
@@ -6,7 +5,7 @@ import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
|
||||
interface Context {
|
||||
directory: string
|
||||
@@ -102,23 +101,33 @@ export const Instance = {
|
||||
if (Instance.worktree === "/") return false
|
||||
return Filesystem.contains(Instance.worktree, filepath)
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
* instance async context (native addons, event emitters, timers, etc.).
|
||||
*/
|
||||
bind<F extends (...args: any[]) => any>(fn: F): F {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
|
||||
cache.delete(Instance.directory)
|
||||
emit(Instance.directory)
|
||||
const directory = Instance.directory
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
emit(directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
@@ -27,50 +28,57 @@ export namespace Vcs {
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
}
|
||||
|
||||
async function currentBranch() {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return
|
||||
const text = result.text().trim()
|
||||
if (!text) return
|
||||
return text
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
return { branch: async () => undefined, unsubscribe: undefined }
|
||||
}
|
||||
let current = await currentBranch()
|
||||
log.info("initialized", { branch: current })
|
||||
|
||||
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await currentBranch()
|
||||
if (next !== current) {
|
||||
log.info("branch changed", { from: current, to: next })
|
||||
current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
branch: async () => current,
|
||||
unsubscribe,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
state.unsubscribe?.()
|
||||
},
|
||||
)
|
||||
|
||||
export async function init() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export async function branch() {
|
||||
return await state().then((s) => s.branch())
|
||||
export namespace VcsService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
|
||||
static readonly layer = Layer.effect(
|
||||
VcsService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let current: string | undefined
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const currentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
current = yield* Effect.promise(() => currentBranch())
|
||||
log.info("initialized", { branch: current })
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await currentBranch()
|
||||
if (next !== current) {
|
||||
log.info("branch changed", { from: current, to: next })
|
||||
current = next
|
||||
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
}
|
||||
|
||||
return VcsService.of({
|
||||
init: Effect.fn("VcsService.init")(function* () {}),
|
||||
branch: Effect.fn("VcsService.branch")(function* () {
|
||||
return current
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Plugin } from "../plugin"
|
||||
import { filter, fromEntries, map, pipe } from "remeda"
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/service"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { filter, fromEntries, map, pipe } from "remeda"
|
||||
import z from "zod"
|
||||
|
||||
export const Method = z
|
||||
@@ -54,21 +51,13 @@ export type ProviderAuthError =
|
||||
|
||||
export namespace ProviderAuthService {
|
||||
export interface Service {
|
||||
/** Get available auth methods for each provider (e.g. OAuth, API key). */
|
||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||
|
||||
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
|
||||
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
||||
|
||||
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
|
||||
readonly callback: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) => Effect.Effect<void, ProviderAuthError>
|
||||
|
||||
/** Set an API key directly for a provider (no OAuth flow). */
|
||||
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,32 +68,29 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
ProviderAuthService,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthService
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.promise(async () => {
|
||||
const methods = pipe(
|
||||
await Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
|
||||
}),
|
||||
)
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
return pipe(
|
||||
await mod.Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||
const x = yield* InstanceState.get(state)
|
||||
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
|
||||
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
}) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const method = s.methods[input.providerID].methods[input.method]
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
const result = yield* Effect.promise(() => method.authorize())
|
||||
s.pending.set(input.providerID, result)
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
@@ -117,17 +103,14 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const match = s.pending.get(input.providerID)
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
|
||||
if (match.method === "code" && !input.code)
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
@@ -148,18 +131,10 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
|
||||
}
|
||||
})
|
||||
|
||||
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: input.key,
|
||||
})
|
||||
})
|
||||
|
||||
return ProviderAuthService.of({
|
||||
methods,
|
||||
authorize,
|
||||
callback,
|
||||
api,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import * as S from "./auth-service"
|
||||
import { ProviderID } from "./schema"
|
||||
|
||||
// Separate runtime: ProviderAuthService can't join the shared runtime because
|
||||
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
|
||||
// AuthService is stateless file I/O so the duplicate instance is harmless.
|
||||
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
|
||||
|
||||
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
|
||||
return rt.runPromise(S.ProviderAuthService.use(f))
|
||||
}
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = S.Method
|
||||
export type Method = S.Method
|
||||
|
||||
export async function methods() {
|
||||
return runPromise((service) => service.methods())
|
||||
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
|
||||
}
|
||||
|
||||
export const Authorization = S.Authorization
|
||||
@@ -30,7 +21,8 @@ export namespace ProviderAuth {
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
|
||||
async (input): Promise<Authorization | undefined> =>
|
||||
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
@@ -39,15 +31,7 @@ export namespace ProviderAuth {
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => runPromise((service) => service.callback(input)),
|
||||
)
|
||||
|
||||
export const api = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
key: z.string(),
|
||||
}),
|
||||
async (input) => runPromise((service) => service.api(input)),
|
||||
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
|
||||
)
|
||||
|
||||
export import OauthMissing = S.OauthMissing
|
||||
|
||||
@@ -167,7 +167,8 @@ export namespace ProviderError {
|
||||
|
||||
export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
|
||||
const m = message(input.providerID, input.error)
|
||||
if (isOverflow(m) || input.error.statusCode === 413) {
|
||||
const body = json(input.error.responseBody)
|
||||
if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
|
||||
return {
|
||||
type: "context_overflow",
|
||||
message: m,
|
||||
|
||||
@@ -197,16 +197,6 @@ export namespace Provider {
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
"github-copilot-enterprise": async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
azure: async (provider) => {
|
||||
const resource = iife(() => {
|
||||
const name = provider.options?.resourceName
|
||||
@@ -863,20 +853,6 @@ export namespace Provider {
|
||||
|
||||
const configProviders = Object.entries(config.provider ?? {})
|
||||
|
||||
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
|
||||
if (database["github-copilot"]) {
|
||||
const githubCopilot = database["github-copilot"]
|
||||
database["github-copilot-enterprise"] = {
|
||||
...githubCopilot,
|
||||
id: ProviderID.githubCopilotEnterprise,
|
||||
name: "GitHub Copilot Enterprise",
|
||||
models: mapValues(githubCopilot.models, (model) => ({
|
||||
...model,
|
||||
providerID: ProviderID.githubCopilotEnterprise,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||
const existing = providers[providerID]
|
||||
if (existing) {
|
||||
@@ -1003,46 +979,16 @@ export namespace Provider {
|
||||
const providerID = ProviderID.make(plugin.auth.provider)
|
||||
if (disabled.has(providerID)) continue
|
||||
|
||||
// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
|
||||
let hasAuth = false
|
||||
const auth = await Auth.get(providerID)
|
||||
if (auth) hasAuth = true
|
||||
|
||||
// Special handling for github-copilot: also check for enterprise auth
|
||||
if (providerID === ProviderID.githubCopilot && !hasAuth) {
|
||||
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
|
||||
if (enterpriseAuth) hasAuth = true
|
||||
}
|
||||
|
||||
if (!hasAuth) continue
|
||||
if (!auth) continue
|
||||
if (!plugin.auth.loader) continue
|
||||
|
||||
// Load for the main provider if auth exists
|
||||
if (auth) {
|
||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
||||
const opts = options ?? {}
|
||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||
mergeProvider(providerID, patch)
|
||||
}
|
||||
|
||||
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
|
||||
if (providerID === ProviderID.githubCopilot) {
|
||||
const enterpriseProviderID = ProviderID.githubCopilotEnterprise
|
||||
if (!disabled.has(enterpriseProviderID)) {
|
||||
const enterpriseAuth = await Auth.get(enterpriseProviderID)
|
||||
if (enterpriseAuth) {
|
||||
const enterpriseOptions = await plugin.auth.loader(
|
||||
() => Auth.get(enterpriseProviderID) as any,
|
||||
database[enterpriseProviderID],
|
||||
)
|
||||
const opts = enterpriseOptions ?? {}
|
||||
const patch: Partial<Info> = providers[enterpriseProviderID]
|
||||
? { options: opts }
|
||||
: { source: "custom", options: opts }
|
||||
mergeProvider(enterpriseProviderID, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
|
||||
@@ -18,7 +18,6 @@ export const ProviderID = providerIdSchema.pipe(
|
||||
google: schema.makeUnsafe("google"),
|
||||
googleVertex: schema.makeUnsafe("google-vertex"),
|
||||
githubCopilot: schema.makeUnsafe("github-copilot"),
|
||||
githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"),
|
||||
amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
|
||||
azure: schema.makeUnsafe("azure"),
|
||||
openrouter: schema.makeUnsafe("openrouter"),
|
||||
|
||||
@@ -167,40 +167,44 @@ export namespace Pty {
|
||||
subscribers: new Map(),
|
||||
}
|
||||
state().set(id, session)
|
||||
ptyProcess.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
ptyProcess.onData(
|
||||
Instance.bind((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
if (ws.data !== key) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (ws.data !== key) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
})
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
remove(id)
|
||||
})
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
)
|
||||
ptyProcess.onExit(
|
||||
Instance.bind(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
remove(id)
|
||||
}),
|
||||
)
|
||||
Bus.publish(Event.Created, { info })
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
import type { QuestionID } from "./schema"
|
||||
import type { SessionID, MessageID } from "@/session/schema"
|
||||
|
||||
function runPromise<A, E>(f: (service: S.QuestionService.Service) => Effect.Effect<A, E>) {
|
||||
return runtime.runPromise(S.QuestionService.use(f))
|
||||
}
|
||||
|
||||
export namespace Question {
|
||||
export const Option = S.Option
|
||||
export type Option = S.Option
|
||||
@@ -27,18 +22,18 @@ export namespace Question {
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromise((service) => service.ask(input))
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||
return runPromise((service) => service.reply(input))
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID): Promise<void> {
|
||||
return runPromise((service) => service.reject(requestID))
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Request[]> {
|
||||
return runPromise((service) => service.list())
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { InstanceState } from "@/util/instance-state"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
@@ -104,18 +103,13 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
||||
static readonly layer = Layer.effect(
|
||||
QuestionService,
|
||||
Effect.gen(function* () {
|
||||
const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>>(() =>
|
||||
Effect.succeed(new Map<QuestionID, PendingEntry>()),
|
||||
)
|
||||
|
||||
const getPending = InstanceState.get(instanceState)
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const pending = yield* getPending
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
@@ -138,7 +132,6 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const pending = yield* getPending
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
@@ -155,7 +148,6 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
||||
const pending = yield* getPending
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
@@ -171,7 +163,6 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
|
||||
})
|
||||
|
||||
const list = Effect.fn("QuestionService.list")(function* () {
|
||||
const pending = yield* getPending
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import { LSP } from "../lsp"
|
||||
import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Vcs } from "../project/vcs"
|
||||
import { Vcs, VcsService } from "../project/vcs"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill/skill"
|
||||
import { Auth } from "../auth"
|
||||
@@ -330,7 +331,7 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await Vcs.branch()
|
||||
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
|
||||
return c.json({
|
||||
branch,
|
||||
})
|
||||
|
||||
@@ -200,6 +200,8 @@ When constructing the summary, try to stick to this template:
|
||||
---`
|
||||
|
||||
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
|
||||
const msgs = structuredClone(messages)
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const result = await processor.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
@@ -208,7 +210,7 @@ When constructing the summary, try to stick to this template:
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(messages, model, { stripMedia: true }),
|
||||
...MessageV2.toModelMessages(msgs, model, { stripMedia: true }),
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
|
||||
@@ -1245,7 +1245,7 @@ export namespace SessionPrompt {
|
||||
]
|
||||
}
|
||||
|
||||
FileTime.read(input.sessionID, filepath)
|
||||
await FileTime.read(input.sessionID, filepath)
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
|
||||
@@ -1,98 +1,118 @@
|
||||
import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
export namespace Discovery {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
type Index = {
|
||||
skills: Array<{
|
||||
name: string
|
||||
description: string
|
||||
files: string[]
|
||||
}>
|
||||
}
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
skills: Schema.Array(IndexSkill),
|
||||
}) {}
|
||||
|
||||
export function dir() {
|
||||
return path.join(Global.Path.cache, "skills")
|
||||
}
|
||||
const skillConcurrency = 4
|
||||
const fileConcurrency = 8
|
||||
|
||||
async function get(url: string, dest: string): Promise<boolean> {
|
||||
if (await Filesystem.exists(dest)) return true
|
||||
return fetch(url)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
log.error("failed to download", { url, status: response.status })
|
||||
return false
|
||||
}
|
||||
if (response.body) await Filesystem.writeStream(dest, response.body)
|
||||
return true
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export async function pull(url: string): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const cache = dir()
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
log.info("fetching index", { url: index })
|
||||
const data = await fetch(index)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
log.error("failed to fetch index", { url: index, status: response.status })
|
||||
return undefined
|
||||
}
|
||||
return response
|
||||
.json()
|
||||
.then((json) => json as Index)
|
||||
.catch((err) => {
|
||||
log.error("failed to parse index", { url: index, err })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!data?.skills || !Array.isArray(data.skills)) {
|
||||
log.warn("invalid index format", { url: index })
|
||||
return result
|
||||
}
|
||||
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill?.name || !Array.isArray(skill.files)) {
|
||||
log.warn("invalid skill entry", { url: index, skill })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
list.map(async (skill) => {
|
||||
const root = path.join(cache, skill.name)
|
||||
await Promise.all(
|
||||
skill.files.map(async (file) => {
|
||||
const link = new URL(file, `${host}/${skill.name}/`).href
|
||||
const dest = path.join(root, file)
|
||||
await mkdir(path.dirname(dest), { recursive: true })
|
||||
await get(link, dest)
|
||||
}),
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
if (await Filesystem.exists(md)) result.push(root)
|
||||
}),
|
||||
)
|
||||
|
||||
return result
|
||||
export namespace DiscoveryService {
|
||||
export interface Service {
|
||||
readonly pull: (url: string) => Effect.Effect<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
|
||||
"@opencode/SkillDiscovery",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
DiscoveryService,
|
||||
Effect.gen(function* () {
|
||||
const log = Log.create({ service: "skill-discovery" })
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||
const cache = path.join(Global.Path.cache, "skills")
|
||||
|
||||
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
|
||||
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
|
||||
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((res) => res.arrayBuffer),
|
||||
Effect.flatMap((body) =>
|
||||
fs
|
||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
||||
),
|
||||
Effect.as(true),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to download", { url, err })
|
||||
return false
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
|
||||
const base = url.endsWith("/") ? url : `${url}/`
|
||||
const index = new URL("index.json", base).href
|
||||
const host = base.slice(0, -1)
|
||||
|
||||
log.info("fetching index", { url: index })
|
||||
|
||||
const data = yield* HttpClientRequest.get(index).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
http.execute,
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
log.error("failed to fetch index", { url: index, err })
|
||||
return null
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (!data) return []
|
||||
|
||||
const list = data.skills.filter((skill) => {
|
||||
if (!skill.files.includes("SKILL.md")) {
|
||||
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const dirs = yield* Effect.forEach(
|
||||
list,
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
|
||||
return dirs.filter((dir): dir is string => dir !== null)
|
||||
})
|
||||
|
||||
return DiscoveryService.of({ pull })
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = DiscoveryService.layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,15 +10,25 @@ import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import { Discovery } from "./discovery"
|
||||
import { DiscoveryService } from "./discovery"
|
||||
import { Glob } from "../util/glob"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
|
||||
// External skill directories to search for (project-level and global)
|
||||
// These follow the directory layout used by Claude Code and other agents.
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export namespace Skill {
|
||||
const log = Log.create({ service: "skill" })
|
||||
export const Info = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -45,155 +55,20 @@ export namespace Skill {
|
||||
}),
|
||||
)
|
||||
|
||||
// External skill directories to search for (project-level and global)
|
||||
// These follow the directory layout used by Claude Code and other agents.
|
||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const skills: Record<string, Info> = {}
|
||||
const dirs = new Set<string>()
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch((err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
// Warn on duplicate skill names
|
||||
if (skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
dirs.add(path.dirname(match))
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||
.catch((error) => {
|
||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||
// Load global (home) first, then project-level (so project-level overwrites)
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scanExternal(root, "global")
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: Instance.directory,
|
||||
stop: Instance.worktree,
|
||||
})) {
|
||||
await scanExternal(root, "project")
|
||||
}
|
||||
}
|
||||
|
||||
// Scan .opencode/skill/ directories
|
||||
for (const dir of await Config.directories()) {
|
||||
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan additional skill paths from config
|
||||
const config = await Config.get()
|
||||
for (const skillPath of config.skills?.paths ?? []) {
|
||||
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
|
||||
const resolved = path.isAbsolute(expanded) ? expanded : path.join(Instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(resolved))) {
|
||||
log.warn("skill path not found", { path: resolved })
|
||||
continue
|
||||
}
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: resolved,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Download and load skills from URLs
|
||||
for (const url of config.skills?.urls ?? []) {
|
||||
const list = await Discovery.pull(url)
|
||||
for (const dir of list) {
|
||||
dirs.add(dir)
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skills,
|
||||
dirs: Array.from(dirs),
|
||||
}
|
||||
})
|
||||
|
||||
export async function get(name: string) {
|
||||
return state().then((x) => x.skills[name])
|
||||
return runPromiseInstance(SkillService.use((s) => s.get(name)))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return state().then((x) => Object.values(x.skills))
|
||||
return runPromiseInstance(SkillService.use((s) => s.all()))
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return state().then((x) => x.dirs)
|
||||
return runPromiseInstance(SkillService.use((s) => s.dirs()))
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
const list = await all()
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
|
||||
}
|
||||
|
||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
@@ -216,3 +91,177 @@ export namespace Skill {
|
||||
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SkillService {
|
||||
export interface Service {
|
||||
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Skill.Info[]>
|
||||
readonly dirs: () => Effect.Effect<string[]>
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
|
||||
static readonly layer = Layer.effect(
|
||||
SkillService,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* DiscoveryService
|
||||
|
||||
const skills: Record<string, Skill.Info> = {}
|
||||
const skillDirs = new Set<string>()
|
||||
let task: Promise<void> | undefined
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!md) return
|
||||
|
||||
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
// Warn on duplicate skill names
|
||||
if (skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
|
||||
skillDirs.add(path.dirname(match))
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||
.catch((error) => {
|
||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
function ensureScanned() {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||
// Load global (home) first, then project-level (so project-level overwrites)
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scanExternal(root, "global")
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scanExternal(root, "project")
|
||||
}
|
||||
}
|
||||
|
||||
// Scan .opencode/skill/ directories
|
||||
for (const dir of await Config.directories()) {
|
||||
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan additional skill paths from config
|
||||
const config = await Config.get()
|
||||
for (const skillPath of config.skills?.paths ?? []) {
|
||||
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
|
||||
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(resolved))) {
|
||||
log.warn("skill path not found", { path: resolved })
|
||||
continue
|
||||
}
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: resolved,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Download and load skills from URLs
|
||||
for (const url of config.skills?.urls ?? []) {
|
||||
const list = await Effect.runPromise(discovery.pull(url))
|
||||
for (const dir of list) {
|
||||
skillDirs.add(dir)
|
||||
const matches = await Glob.scan(SKILL_PATTERN, {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
})
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(skills).length })
|
||||
})().catch((err) => {
|
||||
task = undefined
|
||||
throw err
|
||||
})
|
||||
return task
|
||||
}
|
||||
|
||||
return SkillService.of({
|
||||
get: Effect.fn("SkillService.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return skills[name]
|
||||
}),
|
||||
all: Effect.fn("SkillService.all")(function* () {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return Object.values(skills)
|
||||
}),
|
||||
dirs: Effect.fn("SkillService.dirs")(function* () {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
return Array.from(skillDirs)
|
||||
}),
|
||||
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => ensureScanned())
|
||||
const list = Object.values(skills)
|
||||
if (!agent) return list
|
||||
return list.filter(
|
||||
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(DiscoveryService.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const EditTool = Tool.define("edit", {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export const EditTool = Tool.define("edit", {
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
|
||||
@@ -214,7 +214,7 @@ export const ReadTool = Tool.define("read", {
|
||||
|
||||
// just warms the lsp client
|
||||
LSP.touchFile(filepath, false)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
if (instructions.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
|
||||
|
||||
@@ -49,7 +49,7 @@ export const WriteTool = Tool.define("write", {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
await LSP.touchFile(filepath, true)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
type Disposer = (directory: string) => Effect.Effect<void>
|
||||
const disposers = new Set<Disposer>()
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
/**
|
||||
* Effect version of `Instance.state` — lazily-initialized, per-directory
|
||||
* cached state for Effect services.
|
||||
*
|
||||
* Values are created on first access for a given directory and cached for
|
||||
* subsequent reads. Concurrent access shares a single initialization —
|
||||
* no duplicate work or races. Use `Effect.acquireRelease` in `init` if
|
||||
* the value needs cleanup on disposal.
|
||||
*/
|
||||
export interface InstanceState<A, E = never, R = never> {
|
||||
readonly [TypeId]: typeof TypeId
|
||||
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||
}
|
||||
|
||||
export namespace InstanceState {
|
||||
/** Create a new InstanceState with the given initializer. */
|
||||
export const make = <A, E = never, R = never>(
|
||||
init: (directory: string) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: init,
|
||||
})
|
||||
|
||||
const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory)
|
||||
disposers.add(disposer)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer)))
|
||||
|
||||
return {
|
||||
[TypeId]: TypeId,
|
||||
cache,
|
||||
}
|
||||
})
|
||||
|
||||
/** Get the cached value for the current directory, initializing it if needed. */
|
||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||
|
||||
/** Check whether a value exists for the current directory. */
|
||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||
|
||||
/** Invalidate the cached value for the current directory. */
|
||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||
|
||||
/** Invalidate the given directory across all InstanceState caches. */
|
||||
export const dispose = (directory: string) =>
|
||||
Effect.all(
|
||||
[...disposers].map((disposer) => disposer(directory)),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { buffer } from "node:stream/consumers"
|
||||
|
||||
export namespace Process {
|
||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||
export type Shell = boolean | string
|
||||
|
||||
export interface Options {
|
||||
cwd?: string
|
||||
@@ -10,6 +11,7 @@ export namespace Process {
|
||||
stdin?: Stdio
|
||||
stdout?: Stdio
|
||||
stderr?: Stdio
|
||||
shell?: Shell
|
||||
abort?: AbortSignal
|
||||
kill?: NodeJS.Signals | number
|
||||
timeout?: number
|
||||
@@ -60,6 +62,7 @@ export namespace Process {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
shell: opts.shell,
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
@@ -391,4 +392,461 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.status()", () => {
|
||||
test("detects modified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "file.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBeGreaterThan(0)
|
||||
expect(entry!.removed).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects untracked file as added", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "new.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("added")
|
||||
expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects deleted file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "gone.txt")
|
||||
await fs.writeFile(filepath, "content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.rm(filepath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
|
||||
const entries = result.filter((f) => f.path === "gone.txt")
|
||||
expect(entries.some((e) => e.status === "deleted")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects mixed changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet()
|
||||
|
||||
// Modify one, delete one, add one
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "remove.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
|
||||
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
|
||||
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for non-git project", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for clean repo", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses binary numstat as 0", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "data.bin")
|
||||
// Write content with null bytes so git treats it as binary
|
||||
const binaryData = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) binaryData[i] = i
|
||||
await fs.writeFile(filepath, binaryData)
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
// Modify the binary
|
||||
const modified = Buffer.alloc(512)
|
||||
for (let i = 0; i < 512; i++) modified[i] = i % 256
|
||||
await fs.writeFile(filepath, modified)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "data.bin")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBe(0)
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list()", () => {
|
||||
test("returns files and directories with correct shape", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
for (const node of nodes) {
|
||||
expect(node).toHaveProperty("name")
|
||||
expect(node).toHaveProperty("path")
|
||||
expect(node).toHaveProperty("absolute")
|
||||
expect(node).toHaveProperty("type")
|
||||
expect(node).toHaveProperty("ignored")
|
||||
expect(["file", "directory"]).toContain(node.type)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("sorts directories before files, alphabetical within each", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "beta"))
|
||||
await fs.mkdir(path.join(tmp.path, "alpha"))
|
||||
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const dirs = nodes.filter((n) => n.type === "directory")
|
||||
const files = nodes.filter((n) => n.type === "file")
|
||||
// Dirs come first
|
||||
const firstFile = nodes.findIndex((n) => n.type === "file")
|
||||
const lastDir = nodes.findLastIndex((n) => n.type === "directory")
|
||||
if (lastDir >= 0 && firstFile >= 0) {
|
||||
expect(lastDir).toBeLessThan(firstFile)
|
||||
}
|
||||
// Alphabetical within dirs
|
||||
expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted())
|
||||
// Alphabetical within files
|
||||
expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted())
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes .git and .DS_Store", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const names = nodes.map((n) => n.name)
|
||||
expect(names).not.toContain(".git")
|
||||
expect(names).not.toContain(".DS_Store")
|
||||
expect(names).toContain("visible.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks gitignored files as ignored", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "build"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const logNode = nodes.find((n) => n.name === "app.log")
|
||||
const tsNode = nodes.find((n) => n.name === "main.ts")
|
||||
const buildNode = nodes.find((n) => n.name === "build")
|
||||
expect(logNode?.ignored).toBe(true)
|
||||
expect(tsNode?.ignored).toBe(false)
|
||||
expect(buildNode?.ignored).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("lists subdirectory contents", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "sub"))
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
|
||||
// Paths should be relative to project root (normalize for Windows)
|
||||
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.list("../outside")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("works without git", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
// Without git, ignored should be false for all
|
||||
for (const node of nodes) {
|
||||
expect(node.ignored).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.search()", () => {
|
||||
async function setupSearchableRepo() {
|
||||
const tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(path.join(tmp.path, ".hidden"))
|
||||
await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8")
|
||||
return tmp
|
||||
}
|
||||
|
||||
test("empty query returns files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Find first hidden dir index
|
||||
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
if (firstHidden >= 0 && lastVisible >= 0) {
|
||||
expect(firstHidden).toBeGreaterThan(lastVisible)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("fuzzy matches file names", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
// Files don't end with /
|
||||
for (const f of result) {
|
||||
expect(f.endsWith("/")).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only directories", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
// Directories end with /
|
||||
for (const d of result) {
|
||||
expect(d.endsWith("/")).toBe(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("respects limit", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("query starting with dot prefers hidden files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toContain(".hidden")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - diff/patch", () => {
|
||||
test("returns diff and patch for modified tracked file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified content\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("modified content")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.diff).toContain("original content")
|
||||
expect(result.diff).toContain("modified content")
|
||||
expect(result.patch).toBeDefined()
|
||||
expect(result.patch!.hunks.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns diff for staged changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "staged.txt")
|
||||
await fs.writeFile(filepath, "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "after\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("staged.txt")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.patch).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns no diff for unmodified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "clean.txt")
|
||||
await fs.writeFile(filepath, "unchanged\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("clean.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("unchanged")
|
||||
expect(result.diff).toBeUndefined()
|
||||
expect(result.patch).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, afterEach } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
function gate() {
|
||||
let open!: () => void
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
open = resolve
|
||||
})
|
||||
return { open, wait }
|
||||
}
|
||||
|
||||
describe("file/time", () => {
|
||||
const sessionID = "test-session-123"
|
||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
||||
|
||||
describe("read() and get()", () => {
|
||||
test("stores read timestamp", async () => {
|
||||
@@ -18,12 +34,12 @@ describe("file/time", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const before = FileTime.get(sessionID, filepath)
|
||||
const before = await FileTime.get(sessionID, filepath)
|
||||
expect(before).toBeUndefined()
|
||||
|
||||
FileTime.read(sessionID, filepath)
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const after = FileTime.get(sessionID, filepath)
|
||||
const after = await FileTime.get(sessionID, filepath)
|
||||
expect(after).toBeInstanceOf(Date)
|
||||
expect(after!.getTime()).toBeGreaterThan(0)
|
||||
},
|
||||
@@ -38,11 +54,11 @@ describe("file/time", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read("session1", filepath)
|
||||
FileTime.read("session2", filepath)
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||
|
||||
const time1 = FileTime.get("session1", filepath)
|
||||
const time2 = FileTime.get("session2", filepath)
|
||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||
|
||||
expect(time1).toBeDefined()
|
||||
expect(time2).toBeDefined()
|
||||
@@ -58,15 +74,13 @@ describe("file/time", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
const first = FileTime.get(sessionID, filepath)!
|
||||
await FileTime.read(sessionID, filepath)
|
||||
const first = await FileTime.get(sessionID, filepath)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await FileTime.read(sessionID, filepath)
|
||||
const second = await FileTime.get(sessionID, filepath)
|
||||
|
||||
FileTime.read(sessionID, filepath)
|
||||
const second = FileTime.get(sessionID, filepath)!
|
||||
|
||||
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
|
||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -77,13 +91,12 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
// Should not throw
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
@@ -106,18 +119,14 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
|
||||
// Wait to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Modify file after reading
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||
|
||||
await touch(filepath, 2_000)
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
@@ -127,13 +136,14 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
let error: Error | undefined
|
||||
try {
|
||||
@@ -147,28 +157,6 @@ describe("file/time", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Flag } = await import("../../src/flag/flag")
|
||||
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
|
||||
|
||||
try {
|
||||
// Should not throw even though file wasn't read
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
} finally {
|
||||
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("withLock()", () => {
|
||||
@@ -212,25 +200,27 @@ describe("file/time", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const order: number[] = []
|
||||
const hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath, async () => {
|
||||
order.push(1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
ready.open()
|
||||
await hold.wait
|
||||
order.push(2)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath, async () => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
})
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
hold.open()
|
||||
|
||||
// Operations should be serialized
|
||||
expect(order).toContain(1)
|
||||
expect(order).toContain(2)
|
||||
expect(order).toContain(3)
|
||||
expect(order).toContain(4)
|
||||
await Promise.all([op1, op2])
|
||||
expect(order).toEqual([1, 2, 3, 4])
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -245,19 +235,24 @@ describe("file/time", () => {
|
||||
fn: async () => {
|
||||
let started1 = false
|
||||
let started2 = false
|
||||
const hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath1, async () => {
|
||||
started1 = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(started2).toBe(true) // op2 should have started while op1 is running
|
||||
ready.open()
|
||||
await hold.wait
|
||||
expect(started2).toBe(true)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath2, async () => {
|
||||
started2 = true
|
||||
hold.open()
|
||||
})
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
|
||||
expect(started1).toBe(true)
|
||||
expect(started2).toBe(true)
|
||||
},
|
||||
@@ -277,7 +272,6 @@ describe("file/time", () => {
|
||||
}),
|
||||
).rejects.toThrow("Test error")
|
||||
|
||||
// Lock should be released, subsequent operations should work
|
||||
let executed = false
|
||||
await FileTime.withLock(filepath, async () => {
|
||||
executed = true
|
||||
@@ -286,31 +280,6 @@ describe("file/time", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("deadlocks on nested locks (expected behavior)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Nested locks on same file cause deadlock - this is expected
|
||||
// The outer lock waits for inner to complete, but inner waits for outer to release
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Deadlock detected")), 100),
|
||||
)
|
||||
|
||||
const nestedLock = FileTime.withLock(filepath, async () => {
|
||||
return FileTime.withLock(filepath, async () => {
|
||||
return "inner"
|
||||
})
|
||||
})
|
||||
|
||||
// Should timeout due to deadlock
|
||||
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("stat() Filesystem.stat pattern", () => {
|
||||
@@ -318,17 +287,17 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const stats = Filesystem.stat(filepath)
|
||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
||||
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
||||
|
||||
// FileTime.assert uses this stat internally
|
||||
await FileTime.assert(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
@@ -338,17 +307,17 @@ describe("file/time", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(sessionID, filepath)
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const originalStat = Filesystem.stat(filepath)
|
||||
|
||||
// Wait and modify
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
const newStat = Filesystem.stat(filepath)
|
||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
||||
|
||||
236
packages/opencode/test/file/watcher.test.ts
Normal file
236
packages/opencode/test/file/watcher.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Fiber, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcherService. */
|
||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
return withServices(
|
||||
directory,
|
||||
FileWatcherService.layer,
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
||||
await Effect.runPromise(ready(directory))
|
||||
await Effect.runPromise(body)
|
||||
},
|
||||
{ provide: [watcherConfigLayer] },
|
||||
)
|
||||
}
|
||||
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
function on(evt: BusUpdate) {
|
||||
if (done) return
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||
if (!check(evt.payload.properties)) return
|
||||
hit(evt.payload.properties)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
done = true
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
return Effect.callback<WatcherEvent>((resume) => {
|
||||
const cleanup = listen(directory, check, (evt) => {
|
||||
cleanup()
|
||||
resume(Effect.succeed(evt))
|
||||
})
|
||||
return Effect.sync(cleanup)
|
||||
}).pipe(Effect.timeout("5 seconds"))
|
||||
}
|
||||
|
||||
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })),
|
||||
(fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
Fiber.interrupt,
|
||||
)
|
||||
}
|
||||
|
||||
/** Effect that asserts no matching event arrives within `ms`. */
|
||||
function noUpdate<E>(
|
||||
directory: string,
|
||||
check: (evt: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
ms = 500,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
listen(directory, check, (evt) => {
|
||||
Effect.runSync(Deferred.succeed(deferred, evt))
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
|
||||
}),
|
||||
(cleanup) => Effect.sync(cleanup),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* nextUpdate(
|
||||
directory,
|
||||
(evt) => evt.file === file && evt.event === "add",
|
||||
Effect.promise(() => fs.writeFile(file, "ready")),
|
||||
).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
|
||||
|
||||
const git = yield* Effect.promise(() =>
|
||||
fs
|
||||
.stat(head)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
if (!git) return
|
||||
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
|
||||
yield* nextUpdate(
|
||||
directory,
|
||||
(evt) => evt.file === head && evt.event !== "unlink",
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeWatcher("FileWatcherService", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("publishes root create, update, and delete events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const file = path.join(tmp.path, "watch.txt")
|
||||
const dir = tmp.path
|
||||
const cases = [
|
||||
{ event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
|
||||
{ event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
|
||||
{ event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
|
||||
]
|
||||
|
||||
await withWatcher(
|
||||
dir,
|
||||
Effect.forEach(cases, ({ event, trigger }) =>
|
||||
nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
|
||||
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("watches non-git roots", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "plain.txt")
|
||||
const dir = tmp.path
|
||||
|
||||
await withWatcher(
|
||||
dir,
|
||||
nextUpdate(
|
||||
dir,
|
||||
(e) => e.file === file && e.event === "add",
|
||||
Effect.promise(() => fs.writeFile(file, "plain")),
|
||||
).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
|
||||
)
|
||||
})
|
||||
|
||||
test("cleanup stops publishing events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
|
||||
// Start and immediately stop the watcher (withWatcher disposes on exit)
|
||||
await withWatcher(tmp.path, Effect.void)
|
||||
|
||||
// Now write a file — no watcher should be listening
|
||||
await Effect.runPromise(
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === file,
|
||||
Effect.promise(() => fs.writeFile(file, "gone")),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("ignores .git/index changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const gitIndex = path.join(tmp.path, ".git", "index")
|
||||
const edit = path.join(tmp.path, "tracked.txt")
|
||||
|
||||
await withWatcher(
|
||||
tmp.path,
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === gitIndex,
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(edit, "a")
|
||||
await $`git add .`.cwd(tmp.path).quiet().nothrow()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("publishes .git/HEAD events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withWatcher(
|
||||
tmp.path,
|
||||
nextUpdate(
|
||||
tmp.path,
|
||||
(evt) => evt.file === head && evt.event !== "unlink",
|
||||
Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
|
||||
).pipe(
|
||||
Effect.tap((evt) =>
|
||||
Effect.sync(() => {
|
||||
expect(evt.file).toBe(head)
|
||||
expect(["add", "change"]).toContain(evt.event)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
47
packages/opencode/test/fixture/instance.ts
Normal file
47
packages/opencode/test/fixture/instance.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
|
||||
import { InstanceContext } from "../../src/effect/instance-context"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
/** ConfigProvider that enables the experimental file watcher. */
|
||||
export const watcherConfigLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Boot an Instance with the given service layers and run `body` with
|
||||
* the ManagedRuntime. Cleanup is automatic — the runtime is disposed
|
||||
* and Instance context is torn down when `body` completes.
|
||||
*
|
||||
* Layers may depend on InstanceContext (provided automatically).
|
||||
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
|
||||
*/
|
||||
export function withServices<S>(
|
||||
directory: string,
|
||||
layer: Layer.Layer<S, any, InstanceContext>,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
|
||||
options?: { provide?: Layer.Layer<never>[] },
|
||||
) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||
)
|
||||
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
|
||||
if (options?.provide) {
|
||||
for (const l of options.provide) {
|
||||
resolved = resolved.pipe(Layer.provide(l)) as any
|
||||
}
|
||||
}
|
||||
const rt = ManagedRuntime.make(resolved)
|
||||
try {
|
||||
await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
64
packages/opencode/test/format/format.test.ts
Normal file
64
packages/opencode/test/format/format.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { withServices } from "../fixture/instance"
|
||||
import { FormatService } from "../../src/format"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
describe("FormatService", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("status() returns built-in formatters when no config overrides", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
expect(Array.isArray(statuses)).toBe(true)
|
||||
expect(statuses.length).toBeGreaterThan(0)
|
||||
|
||||
for (const s of statuses) {
|
||||
expect(typeof s.name).toBe("string")
|
||||
expect(Array.isArray(s.extensions)).toBe(true)
|
||||
expect(typeof s.enabled).toBe("boolean")
|
||||
}
|
||||
|
||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
})
|
||||
})
|
||||
|
||||
test("status() returns empty list when formatter is disabled", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: { formatter: false },
|
||||
})
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
expect(statuses).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("status() excludes formatters marked as disabled in config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: { disabled: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
|
||||
const gofmt = statuses.find((s) => s.name === "gofmt")
|
||||
expect(gofmt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("init() completes without error", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withServices(tmp.path, FormatService.layer, async (rt) => {
|
||||
await rt.runPromise(FormatService.use((s) => s.init()))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { runtime } from "../../src/effect/runtime"
|
||||
import { Instances } from "../../src/effect/instances"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import * as S from "../../src/permission/service"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
@@ -9,6 +11,10 @@ import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function rejectAll(message?: string) {
|
||||
for (const req of await PermissionNext.list()) {
|
||||
await PermissionNext.reply({
|
||||
@@ -971,7 +977,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const err = await PermissionNext.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -981,24 +987,12 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
{ permission: "bash", pattern: "echo *", action: "ask" },
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
})
|
||||
}).then(
|
||||
() => undefined,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
const out = await Promise.race([
|
||||
ask.then(
|
||||
() => ({ ok: true as const, err: undefined }),
|
||||
(err) => ({ ok: false as const, err }),
|
||||
),
|
||||
Bun.sleep(100).then(() => "timeout" as const),
|
||||
])
|
||||
|
||||
if (out === "timeout") {
|
||||
await rejectAll()
|
||||
await ask.catch(() => {})
|
||||
throw new Error("ask timed out instead of denying immediately")
|
||||
}
|
||||
|
||||
expect(out.ok).toBe(false)
|
||||
expect(out.err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
@@ -1020,7 +1014,7 @@ test("ask - abort should clear pending request", async () => {
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.provide(Instances.get(Instance.directory))),
|
||||
{ signal: ctl.signal },
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ process.env["OPENCODE_TEST_HOME"] = testHome
|
||||
// Set test managed config directory to isolate tests from system managed settings
|
||||
const testManagedConfigDir = path.join(dir, "managed")
|
||||
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
|
||||
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
|
||||
|
||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||
const cacheDir = path.join(dir, "cache", "opencode")
|
||||
|
||||
117
packages/opencode/test/project/vcs.test.ts
Normal file
117
packages/opencode/test/project/vcs.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Vcs, VcsService } from "../../src/project/vcs"
|
||||
|
||||
// Skip in CI — native @parcel/watcher binding needed
|
||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withVcs(
|
||||
directory: string,
|
||||
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
|
||||
) {
|
||||
return withServices(
|
||||
directory,
|
||||
Layer.merge(FileWatcherService.layer, VcsService.layer),
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcherService.use((s) => s.init()))
|
||||
await rt.runPromise(VcsService.use((s) => s.init()))
|
||||
await Bun.sleep(200)
|
||||
await body(rt)
|
||||
},
|
||||
{ provide: [watcherConfigLayer] },
|
||||
)
|
||||
}
|
||||
|
||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||
|
||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
|
||||
function nextBranchUpdate(directory: string, timeout = 5000) {
|
||||
return new Promise<string | undefined>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", on)
|
||||
reject(new Error("timed out waiting for BranchUpdated event"))
|
||||
}, timeout)
|
||||
|
||||
function on(evt: BranchEvent) {
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", on)
|
||||
resolve(evt.payload.properties.branch)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeVcs("Vcs", () => {
|
||||
afterEach(() => Instance.disposeAll())
|
||||
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await withVcs(tmp.path, async (rt) => {
|
||||
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() returns undefined for non-git directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withVcs(tmp.path, async (rt) => {
|
||||
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("publishes BranchUpdated when .git/HEAD changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
|
||||
const updated = await pending
|
||||
expect(updated).toBe(branch)
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() reflects the new branch after HEAD change", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcs(tmp.path, async (rt) => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
|
||||
await pending
|
||||
const current = await rt.runPromise(VcsService.use((s) => s.branch()))
|
||||
expect(current).toBe(branch)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { ProviderAuth } from "../../src/provider/auth"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await Auth.remove("test-provider-auth")
|
||||
})
|
||||
|
||||
test("ProviderAuth.api persists auth via AuthService", async () => {
|
||||
await ProviderAuth.api({
|
||||
providerID: ProviderID.make("test-provider-auth"),
|
||||
key: "sk-test",
|
||||
})
|
||||
|
||||
expect(await Auth.get("test-provider-auth")).toEqual({
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import type { PtyID } from "../../src/pty/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const wait = async (fn: () => boolean, ms = 2000) => {
|
||||
const wait = async (fn: () => boolean, ms = 5000) => {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (fn()) return
|
||||
@@ -20,7 +20,7 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>,
|
||||
}
|
||||
|
||||
describe("pty", () => {
|
||||
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
|
||||
test("publishes created, exited, deleted in order for a short-lived process", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using dir = await tmpdir({ git: true })
|
||||
@@ -37,7 +37,11 @@ describe("pty", () => {
|
||||
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
|
||||
const info = await Pty.create({
|
||||
command: "/usr/bin/env",
|
||||
args: ["sh", "-c", "sleep 0.1"],
|
||||
title: "sleep",
|
||||
})
|
||||
id = info.id
|
||||
|
||||
await wait(() => pick(log, id!).includes("exited"))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { Question } from "../../src/question"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
||||
async function rejectAll() {
|
||||
const pending = await Question.list()
|
||||
|
||||
@@ -869,6 +869,26 @@ describe("session.message-v2.fromError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("detects context overflow from context_length_exceeded code in response body", () => {
|
||||
const error = new APICallError({
|
||||
message: "Request failed",
|
||||
url: "https://example.com",
|
||||
requestBodyValues: {},
|
||||
statusCode: 422,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: JSON.stringify({
|
||||
error: {
|
||||
message: "Some message",
|
||||
type: "invalid_request_error",
|
||||
code: "context_length_exceeded",
|
||||
},
|
||||
}),
|
||||
isRetryable: false,
|
||||
})
|
||||
const result = MessageV2.fromError(error, { providerID })
|
||||
expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not classify 429 no body as context overflow", () => {
|
||||
const result = MessageV2.fromError(
|
||||
new APICallError({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { Effect } from "effect"
|
||||
import { DiscoveryService } from "../../src/skill/discovery"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -9,9 +11,10 @@ let server: ReturnType<typeof Bun.serve>
|
||||
let downloadCount = 0
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
@@ -40,22 +43,25 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("Discovery.pull", () => {
|
||||
const pull = (url: string) =>
|
||||
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
|
||||
|
||||
test("downloads skills from cloudflare url", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
expect(dir).toStartWith(Discovery.dir())
|
||||
expect(dir).toStartWith(cacheDir)
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(await Filesystem.exists(md)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("url without trailing slash works", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
@@ -64,18 +70,18 @@ describe("Discovery.pull", () => {
|
||||
})
|
||||
|
||||
test("returns empty array for invalid url", async () => {
|
||||
const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
const dirs = await pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array for non-json response", async () => {
|
||||
// any url not explicitly handled in server returns 404 text "Not Found"
|
||||
const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
const dirs = await pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
test("downloads reference files alongside SKILL.md", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
|
||||
expect(agentsSdk).toBeDefined()
|
||||
@@ -90,17 +96,17 @@ describe("Discovery.pull", () => {
|
||||
|
||||
test("caches downloaded files on second pull", async () => {
|
||||
// clear dir and downloadCount
|
||||
await rm(Discovery.dir(), { recursive: true, force: true })
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
downloadCount = 0
|
||||
|
||||
// first pull to populate cache
|
||||
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
const first = await pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
const firstCount = downloadCount
|
||||
expect(firstCount).toBeGreaterThan(0)
|
||||
|
||||
// second pull should return same results from cache
|
||||
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
const second = await pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(second.length).toBe(first.length)
|
||||
expect(second.sort()).toEqual(first.sort())
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
@@ -111,7 +116,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
@@ -138,7 +143,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -186,7 +191,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -230,18 +235,17 @@ describe("tool.edit", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Wait a bit to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await EditTool.init()
|
||||
@@ -267,7 +271,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -294,7 +298,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
@@ -332,7 +336,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -358,7 +362,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
@@ -407,7 +411,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, dirpath)
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
@@ -432,7 +436,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
@@ -503,7 +507,7 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath,
|
||||
@@ -644,7 +648,7 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
|
||||
@@ -659,7 +663,7 @@ describe("tool.edit", () => {
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = edit.execute(
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
@@ -128,7 +128,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
const result = await write.execute(
|
||||
@@ -306,7 +306,7 @@ describe("tool.write", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileTime } = await import("../../src/file/time")
|
||||
FileTime.read(ctx.sessionID, readonlyPath)
|
||||
await FileTime.read(ctx.sessionID, readonlyPath)
|
||||
|
||||
const write = await WriteTool.init()
|
||||
await expect(
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceState } from "../../src/util/instance-state"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("InstanceState caches values for the same instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState isolates values by directory", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
|
||||
|
||||
const x = yield* Effect.promise(() => access(state, a.path))
|
||||
const y = yield* Effect.promise(() => access(state, b.path))
|
||||
const z = yield* Effect.promise(() => access(state, a.path))
|
||||
|
||||
expect(x).toBe(z)
|
||||
expect(x).not.toBe(y)
|
||||
expect(n).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on instance reload", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const seen: string[] = []
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ n: ++n })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(String(value.n))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
expect(seen).toEqual(["1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState is disposed on disposeAll", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
const seen: string[] = []
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ dir })),
|
||||
(value) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(value.dir)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => access(state, a.path))
|
||||
yield* Effect.promise(() => access(state, b.path))
|
||||
yield* Effect.promise(() => Instance.disposeAll())
|
||||
|
||||
expect(seen.sort()).toEqual([a.path, b.path].sort())
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
|
||||
// Regression: InstanceState.get must be lazy (Effect.suspend) so the
|
||||
// directory is read per-evaluation, not captured once at the call site.
|
||||
// Without this, a service built inside a ManagedRuntime Layer would
|
||||
// freeze to whichever directory triggered the first layer build.
|
||||
|
||||
interface TestApi {
|
||||
readonly getDir: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-lazy") {
|
||||
static readonly layer = Layer.effect(
|
||||
TestService,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
|
||||
// `get` is created once during layer build — must be lazy
|
||||
const get = InstanceState.get(state)
|
||||
|
||||
const getDir = Effect.fn("TestService.getDir")(function* () {
|
||||
return yield* get
|
||||
})
|
||||
|
||||
return TestService.of({ getDir })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(TestService.layer)
|
||||
|
||||
try {
|
||||
const resultA = await Instance.provide({
|
||||
directory: a.path,
|
||||
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||
})
|
||||
expect(resultA).toBe(a.path)
|
||||
|
||||
// Second call with different directory must NOT return A's directory
|
||||
const resultB = await Instance.provide({
|
||||
directory: b.path,
|
||||
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||
})
|
||||
expect(resultB).toBe(b.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => {
|
||||
await using a = await tmpdir()
|
||||
await using b = await tmpdir()
|
||||
await using c = await tmpdir()
|
||||
|
||||
// Adversarial: concurrent fibers with real timer delays (macrotask
|
||||
// boundaries via setTimeout/Bun.sleep), explicit scheduler yields,
|
||||
// and many async steps. If ALS context leaks or gets lost at any
|
||||
// point, a fiber will see the wrong directory.
|
||||
|
||||
interface TestApi {
|
||||
readonly getDir: () => Effect.Effect<string>
|
||||
}
|
||||
|
||||
class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-adversarial") {
|
||||
static readonly layer = Layer.effect(
|
||||
TestService,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
|
||||
|
||||
const getDir = Effect.fn("TestService.getDir")(function* () {
|
||||
// Mix of async boundary types to maximise interleaving:
|
||||
// 1. Real timer delay (macrotask — setTimeout under the hood)
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
// 2. Effect.sleep (Effect's own timer, uses its internal scheduler)
|
||||
yield* Effect.sleep(Duration.millis(1))
|
||||
// 3. Explicit scheduler yields
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
// 4. Microtask boundaries
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield* Effect.promise(() => Promise.resolve())
|
||||
}
|
||||
// 5. Another Effect.sleep
|
||||
yield* Effect.sleep(Duration.millis(2))
|
||||
// 6. Another real timer to force a second macrotask hop
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
// NOW read the directory — ALS must still be correct
|
||||
return yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
return TestService.of({ getDir })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const rt = ManagedRuntime.make(TestService.layer)
|
||||
|
||||
try {
|
||||
const [resultA, resultB, resultC] = await Promise.all([
|
||||
Instance.provide({
|
||||
directory: a.path,
|
||||
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: b.path,
|
||||
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||
}),
|
||||
Instance.provide({
|
||||
directory: c.path,
|
||||
fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
|
||||
}),
|
||||
])
|
||||
|
||||
expect(resultA).toBe(a.path)
|
||||
expect(resultB).toBe(b.path)
|
||||
expect(resultC).toBe(c.path)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let n = 0
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make(() =>
|
||||
Effect.promise(async () => {
|
||||
n += 1
|
||||
await Bun.sleep(10)
|
||||
return { n }
|
||||
}),
|
||||
)
|
||||
|
||||
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||
expect(a).toBe(b)
|
||||
expect(n).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -54,35 +54,6 @@ export type EventServerInstanceDisposed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
always: Array<string>
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPermissionAsked = {
|
||||
type: "permission.asked"
|
||||
properties: PermissionRequest
|
||||
}
|
||||
|
||||
export type EventPermissionReplied = {
|
||||
type: "permission.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
reply: "once" | "always" | "reject"
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -154,6 +125,57 @@ export type EventQuestionRejected = {
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
always: Array<string>
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventPermissionAsked = {
|
||||
type: "permission.asked"
|
||||
properties: PermissionRequest
|
||||
}
|
||||
|
||||
export type EventPermissionReplied = {
|
||||
type: "permission.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
reply: "once" | "always" | "reject"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
@@ -183,13 +205,6 @@ export type EventLspUpdated = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type OutputFormatText = {
|
||||
type: "text"
|
||||
}
|
||||
@@ -685,14 +700,6 @@ export type EventSessionCompacted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type Todo = {
|
||||
/**
|
||||
* Brief description of the task
|
||||
@@ -882,13 +889,6 @@ export type EventSessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceReady = {
|
||||
type: "workspace.ready"
|
||||
properties: {
|
||||
@@ -962,16 +962,18 @@ export type Event =
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventProjectUpdated
|
||||
| EventServerInstanceDisposed
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventPermissionAsked
|
||||
| EventPermissionReplied
|
||||
| EventFileWatcherUpdated
|
||||
| EventVcsBranchUpdated
|
||||
| EventFileEdited
|
||||
| EventServerConnected
|
||||
| EventGlobalDisposed
|
||||
| EventLspClientDiagnostics
|
||||
| EventLspUpdated
|
||||
| EventFileEdited
|
||||
| EventMessageUpdated
|
||||
| EventMessageRemoved
|
||||
| EventMessagePartUpdated
|
||||
@@ -980,7 +982,6 @@ export type Event =
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventFileWatcherUpdated
|
||||
| EventTodoUpdated
|
||||
| EventTuiPromptAppend
|
||||
| EventTuiCommandExecute
|
||||
@@ -994,7 +995,6 @@ export type Event =
|
||||
| EventSessionDeleted
|
||||
| EventSessionDiff
|
||||
| EventSessionError
|
||||
| EventVcsBranchUpdated
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventPtyCreated
|
||||
@@ -1343,6 +1343,9 @@ export type Config = {
|
||||
ignore?: Array<string>
|
||||
}
|
||||
plugin?: Array<string>
|
||||
/**
|
||||
* Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.
|
||||
*/
|
||||
snapshot?: boolean
|
||||
/**
|
||||
* Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing
|
||||
|
||||
@@ -7062,96 +7062,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"always": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
},
|
||||
"callID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "callID"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
|
||||
},
|
||||
"Event.permission.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.asked"
|
||||
},
|
||||
"properties": {
|
||||
"$ref": "#/components/schemas/PermissionRequest"
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.permission.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.replied"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"enum": ["once", "always", "reject"]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "reply"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7302,6 +7212,168 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"permission": {
|
||||
"type": "string"
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"always": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
},
|
||||
"callID": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID", "callID"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "permission", "patterns", "metadata", "always"]
|
||||
},
|
||||
"Event.permission.asked": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.asked"
|
||||
},
|
||||
"properties": {
|
||||
"$ref": "#/components/schemas/PermissionRequest"
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.permission.replied": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "permission.replied"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"requestID": {
|
||||
"type": "string",
|
||||
"pattern": "^per.*"
|
||||
},
|
||||
"reply": {
|
||||
"type": "string",
|
||||
"enum": ["once", "always", "reject"]
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "requestID", "reply"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7366,25 +7438,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"OutputFormatText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8866,41 +8919,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9387,24 +9405,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9611,12 +9611,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
@@ -9626,6 +9620,21 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.permission.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
},
|
||||
@@ -9638,9 +9647,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.message.updated"
|
||||
},
|
||||
@@ -9665,9 +9671,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
@@ -9707,9 +9710,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.error"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.ready"
|
||||
},
|
||||
@@ -10405,6 +10405,7 @@
|
||||
}
|
||||
},
|
||||
"snapshot": {
|
||||
"description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"share": {
|
||||
|
||||
@@ -424,6 +424,23 @@ Customize keybinds in `tui.json`.
|
||||
|
||||
---
|
||||
|
||||
### Snapshot
|
||||
|
||||
OpenCode uses snapshots to track file changes during agent operations, enabling you to undo and revert changes within a session. Snapshots are enabled by default.
|
||||
|
||||
For large repositories or projects with many submodules, the snapshot system can cause slow indexing and significant disk usage as it tracks all changes using an internal git repository. You can disable snapshots using the `snapshot` option.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"snapshot": false
|
||||
}
|
||||
```
|
||||
|
||||
Note that disabling snapshots means changes made by the agent cannot be rolled back through the UI.
|
||||
|
||||
---
|
||||
|
||||
### Autoupdate
|
||||
|
||||
OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option.
|
||||
|
||||
Reference in New Issue
Block a user