Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton
58ad84d53e refactor(snapshot): effectify SnapshotService as scoped service
Convert Snapshot from a promise-based namespace with Instance ALS reads
to an Effect service on the Instances LayerMap.

- SnapshotService with ChildProcessSpawner for git subprocess execution
  and Effect FileSystem for file operations (replaces Process.run and
  raw fs calls)
- Nothrow git helper that always returns { code, text, stderr }, with
  spawn failure details preserved in stderr
- Hourly cleanup via Effect.forkScoped + Schedule.spaced (replaces
  Scheduler.register)
- Promise facade preserved for all existing callers
- Parallelized before/after git show in diffFull
- Add worktree to InstanceContext.Shape (needed for --work-tree flag)
- Add Instance.current getter for single ALS read
- Extract repeated git config flags into GIT_CORE/GIT_CFG/GIT_CFG_QUOTE
  constants
- Platform layers (NodeChildProcessSpawner, NodeFileSystem, NodePath)
  provided directly on the service layer
2026-03-16 21:44:17 -04:00
Luke Parker
cb69501098 test(opencode): deflake file and tool timing (#17859) 2026-03-17 00:49:04 +00:00
Kyle Altendorf
a64f604d54 fix(tui): check for selected text instead of any selection in dialog escape handler (#16779) 2026-03-17 10:25:03 +10:00
opencode-agent[bot]
d7093abf61 chore: update nix node_modules hashes 2026-03-17 00:05:19 +00:00
opencode-agent[bot]
60af447908 chore: update nix node_modules hashes 2026-03-16 23:54:30 +00:00
opencode-agent[bot]
1cdc558ac0 chore: generate 2026-03-16 23:52:10 +00:00
Kit Langton
3849822769 refactor(skill): effectify SkillService as scoped service (#17849) 2026-03-16 23:51:07 +00:00
AbigailJixiangyuyu
e9a17e4480 fix(windows): restore /editor support on Windows (#17146) 2026-03-17 08:11:02 +10:00
Aiden Cline
68809365df fix: github copilot enterprise integration (#17847) 2026-03-16 17:05:14 -05:00
opencode-agent[bot]
8da511dfa8 chore: generate 2026-03-16 20:19:50 +00:00
23 changed files with 1241 additions and 1020 deletions

View File

@@ -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=="],

View File

@@ -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="
}
}

View File

@@ -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",

View File

@@ -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?.()

View File

@@ -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()
}
}
}

View File

@@ -1,13 +1,15 @@
import { ServiceMap } from "effect"
import type { Project } from "@/project/project"
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 interface Shape {
readonly directory: string;
readonly worktree: string;
readonly project: Project.Info;
}
}
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
"opencode/InstanceContext",
) {}
export class InstanceContext extends ServiceMap.Service<
InstanceContext,
InstanceContext.Shape
>()("opencode/InstanceContext") {}

View File

@@ -1,61 +1,83 @@
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 { Instance } from "@/project/instance"
import { Effect, Layer, LayerMap, ServiceMap } from "effect";
import { FileService } from "@/file";
import { FileTimeService } from "@/file/time";
import { FileWatcherService } from "@/file/watcher";
import { FormatService } from "@/format";
import { PermissionService } from "@/permission/service";
import { Instance } from "@/project/instance";
import { VcsService } from "@/project/vcs";
import { ProviderAuthService } from "@/provider/auth-service";
import { QuestionService } from "@/question/service";
import { SkillService } from "@/skill/skill";
import { SnapshotService } from "@/snapshot";
import { InstanceContext } from "./instance-context";
import { registerDisposer } from "./instance-registry";
export { InstanceContext } from "./instance-context"
export { InstanceContext } from "./instance-context";
export type InstanceServices =
| QuestionService
| PermissionService
| ProviderAuthService
| FileWatcherService
| VcsService
| FileTimeService
| FormatService
| FileService
| QuestionService
| PermissionService
| ProviderAuthService
| FileWatcherService
| VcsService
| FileTimeService
| FormatService
| FileService
| SkillService
| SnapshotService;
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),
).pipe(Layer.provide(ctx))
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
// legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of(Instance.current),
);
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),
Layer.fresh(SnapshotService.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)
}),
)
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 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))
}
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
return Instances.use((map) => map.invalidate(directory));
}
}

View File

@@ -409,9 +409,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises
.readdir(base, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
@@ -450,7 +448,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}
const init = Effect.fn("FileService.init")(function* () {
void kick()
yield* Effect.promise(() => kick())
})
const status = Effect.fn("FileService.status")(function* () {

View File

@@ -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
}

View File

@@ -1,163 +1,185 @@
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { disposeInstance } from "@/effect/instance-registry"
import { GlobalBus } from "@/bus/global";
import { disposeInstance } from "@/effect/instance-registry";
import { Filesystem } from "@/util/filesystem";
import { iife } from "@/util/iife";
import { Log } from "@/util/log";
import { Context } from "../util/context";
import { Project } from "./project";
import { State } from "./state";
interface Context {
directory: string
worktree: string
project: Project.Info
directory: string;
worktree: string;
project: Project.Info;
}
const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>()
const context = Context.create<Context>("instance");
const cache = new Map<string, Promise<Context>>();
const disposal = {
all: undefined as Promise<void> | undefined,
}
all: undefined as Promise<void> | undefined,
};
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
});
}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
function boot(input: {
directory: string;
init?: () => Promise<any>;
project?: Project.Info;
worktree?: string;
}) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await Project.fromDirectory(input.directory).then(
({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}),
);
await context.provide(ctx, async () => {
await input.init?.();
});
return ctx;
});
}
function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory);
throw error;
});
cache.set(directory, task);
return task;
}
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
},
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
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), disposeInstance(directory)])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
return await next
},
async dispose() {
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
async provide<R>(input: {
directory: string;
init?: () => Promise<any>;
fn: () => R;
}): Promise<R> {
const directory = Filesystem.resolve(input.directory);
let existing = cache.get(directory);
if (!existing) {
Log.Default.info("creating instance", { directory });
existing = track(
directory,
boot({
directory,
init: input.init,
}),
);
}
const ctx = await existing;
return context.provide(ctx, async () => {
return input.fn();
});
},
get current() {
return context.use();
},
get directory() {
return context.use().directory;
},
get worktree() {
return context.use().worktree;
},
get project() {
return context.use().project;
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true;
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
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), disposeInstance(directory)]);
cache.delete(directory);
const next = track(directory, boot({ ...input, directory }));
emit(directory);
return await next;
},
async dispose() {
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;
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
disposal.all = iife(async () => {
Log.Default.info("disposing all instances");
const entries = [...cache.entries()];
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue;
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error });
return undefined;
});
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (!ctx) {
if (cache.get(key) === value) cache.delete(key);
continue;
}
if (cache.get(key) !== value) continue
if (cache.get(key) !== value) continue;
await context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
await context.provide(ctx, async () => {
await Instance.dispose();
});
}
}).finally(() => {
disposal.all = undefined;
});
return disposal.all
},
}
return disposal.all;
},
};

View File

@@ -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)) {

View File

@@ -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"),

View File

@@ -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),
)
}

View File

@@ -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))
}

View File

@@ -1,416 +1,516 @@
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
import {
NodeChildProcessSpawner,
NodeFileSystem,
NodePath,
} from "@effect/platform-node";
import {
Cause,
Duration,
Effect,
FileSystem,
Layer,
Schedule,
ServiceMap,
Stream,
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import path from "path";
import z from "zod";
import { InstanceContext } from "@/effect/instance-context";
import { runPromiseInstance } from "@/effect/runtime";
import { Config } from "../config/config";
import { Global } from "../global";
import { Log } from "../util/log";
const log = Log.create({ service: "snapshot" });
const PRUNE = "7.days";
// Common git config flags shared across snapshot operations
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"];
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE];
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"];
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode;
readonly text: string;
readonly stderr: string;
}
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
});
export type Patch = z.infer<typeof Patch>;
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
});
export type FileDiff = z.infer<typeof FileDiff>;
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
// Promise facade — existing callers use these
export function init() {
void runPromiseInstance(SnapshotService.use((s) => s.init()));
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function cleanup() {
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()));
}
export async function track() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
},
nothrow: true,
})
export async function track() {
return runPromiseInstance(SnapshotService.use((s) => s.track()));
}
// Configure git to not convert line endings on Windows
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
export async function patch(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)));
}
export const Patch = z.object({
hash: z.string(),
files: z.string().array(),
})
export type Patch = z.infer<typeof Patch>
export async function restore(snapshot: string) {
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)));
}
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
export async function revert(patches: Patch[]) {
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)));
}
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
export async function diff(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)));
}
const files = result.text
return {
hash,
files: files
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
}
}
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
const files = new Set<string>()
const git = gitdir()
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
} else {
log.info("file did not exist in snapshot, deleting", { file })
await fs.unlink(file).catch(() => {})
}
}
files.add(file)
}
}
}
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text.trim()
}
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
file,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified",
})
}
return result
}
function gitdir() {
const project = Instance.project
return path.join(Global.Path.data, "snapshot", project.id)
}
async function add(git: string) {
await syncExclude(git)
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
}
async function syncExclude(git: string) {
const file = await excludes()
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Filesystem.write(target, "")
return
}
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
}
async function excludes() {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())
.then(() => true)
.catch(() => false)
if (!exists) return
return file.trim()
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)));
}
}
export namespace SnapshotService {
export interface Service {
readonly init: () => Effect.Effect<void>;
readonly cleanup: () => Effect.Effect<void>;
readonly track: () => Effect.Effect<string | undefined>;
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>;
readonly restore: (snapshot: string) => Effect.Effect<void>;
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>;
readonly diff: (hash: string) => Effect.Effect<string>;
readonly diffFull: (
from: string,
to: string,
) => Effect.Effect<Snapshot.FileDiff[]>;
}
}
export class SnapshotService extends ServiceMap.Service<
SnapshotService,
SnapshotService.Service
>()("@opencode/Snapshot") {
static readonly layer = Layer.effect(
SnapshotService,
Effect.gen(function* () {
const ctx = yield* InstanceContext;
const fileSystem = yield* FileSystem.FileSystem;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const { directory, worktree, project } = ctx;
const isGit = project.vcs === "git";
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id);
const gitArgs = (cmd: string[]) => [
"--git-dir",
snapshotGit,
"--work-tree",
worktree,
...cmd,
];
// Run git with nothrow semantics — always returns a result, never fails
const git = (
args: string[],
opts?: { cwd?: string; env?: Record<string, string> },
): Effect.Effect<GitResult> =>
Effect.gen(function* () {
const command = ChildProcess.make("git", args, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
});
const handle = yield* spawner.spawn(command);
const [text, stderr] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
],
{ concurrency: 2 },
);
const code = yield* handle.exitCode;
return { code, text, stderr };
}).pipe(
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
);
// FileSystem helpers — orDie converts PlatformError to defects
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie);
const mkdir = (p: string) =>
fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie);
const writeFile = (p: string, content: string) =>
fileSystem.writeFileString(p, content).pipe(Effect.orDie);
const readFile = (p: string) =>
fileSystem
.readFileString(p)
.pipe(Effect.catch(() => Effect.succeed("")));
const removeFile = (p: string) =>
fileSystem.remove(p).pipe(Effect.catch(() => Effect.void));
// --- internal Effect helpers ---
const isEnabled = Effect.gen(function* () {
if (!isGit) return false;
const cfg = yield* Effect.promise(() => Config.get());
return cfg.snapshot !== false;
});
const excludesPath = Effect.gen(function* () {
const result = yield* git(
["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"],
{
cwd: worktree,
},
);
const file = result.text.trim();
if (!file) return undefined;
if (!(yield* exists(file))) return undefined;
return file;
});
const syncExclude = Effect.gen(function* () {
const file = yield* excludesPath;
const target = path.join(snapshotGit, "info", "exclude");
yield* mkdir(path.join(snapshotGit, "info"));
if (!file) {
yield* writeFile(target, "");
return;
}
const text = yield* readFile(file);
yield* writeFile(target, text);
});
const add = Effect.gen(function* () {
yield* syncExclude;
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory });
});
// --- service methods ---
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
if (!(yield* isEnabled)) return;
if (!(yield* exists(snapshotGit))) return;
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
cwd: directory,
});
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
});
return;
}
log.info("cleanup", { prune: PRUNE });
});
const track = Effect.fn("SnapshotService.track")(function* () {
if (!(yield* isEnabled)) return undefined;
const existed = yield* exists(snapshotGit);
yield* mkdir(snapshotGit);
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
});
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.autocrlf",
"false",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.longpaths",
"true",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.symlinks",
"true",
]);
yield* git([
"--git-dir",
snapshotGit,
"config",
"core.fsmonitor",
"false",
]);
log.info("initialized");
}
yield* add;
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory });
const hash = result.text.trim();
log.info("tracking", { hash, cwd: directory, git: snapshotGit });
return hash;
});
const patch = Effect.fn("SnapshotService.patch")(function* (
hash: string,
) {
yield* add;
const result = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"diff",
"--no-ext-diff",
"--name-only",
hash,
"--",
".",
]),
],
{ cwd: directory },
);
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code });
return { hash, files: [] } as Snapshot.Patch;
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x: string) => x.trim())
.filter(Boolean)
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
} as Snapshot.Patch;
});
const restore = Effect.fn("SnapshotService.restore")(function* (
snapshot: string,
) {
log.info("restore", { commit: snapshot });
const result = yield* git(
[...GIT_CORE, ...gitArgs(["read-tree", snapshot])],
{ cwd: worktree },
);
if (result.code === 0) {
const checkout = yield* git(
[...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])],
{ cwd: worktree },
);
if (checkout.code === 0) return;
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
});
return;
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
});
});
const revert = Effect.fn("SnapshotService.revert")(function* (
patches: Snapshot.Patch[],
) {
const seen = new Set<string>();
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue;
log.info("reverting", { file, hash: item.hash });
const result = yield* git(
[...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])],
{
cwd: worktree,
},
);
if (result.code !== 0) {
const relativePath = path.relative(worktree, file);
const checkTree = yield* git(
[
...GIT_CORE,
...gitArgs(["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: worktree,
},
);
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info(
"file existed in snapshot but checkout failed, keeping",
{ file },
);
} else {
log.info("file did not exist in snapshot, deleting", { file });
yield* removeFile(file);
}
}
seen.add(file);
}
}
});
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
yield* add;
const result = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs(["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: worktree,
},
);
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
});
return "";
}
return result.text.trim();
});
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (
from: string,
to: string,
) {
const result: Snapshot.FileDiff[] = [];
const status = new Map<string, "added" | "deleted" | "modified">();
const statuses = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"diff",
"--no-ext-diff",
"--name-status",
"--no-renames",
from,
to,
"--",
".",
]),
],
{ cwd: directory },
);
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue;
const [code, file] = line.split("\t");
if (!code || !file) continue;
const kind = code.startsWith("A")
? "added"
: code.startsWith("D")
? "deleted"
: "modified";
status.set(file, kind);
}
const numstat = yield* git(
[
...GIT_CFG_QUOTE,
...gitArgs([
"diff",
"--no-ext-diff",
"--no-renames",
"--numstat",
from,
to,
"--",
".",
]),
],
{ cwd: directory },
);
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue;
const [additions, deletions, file] = line.split("\t");
const isBinaryFile = additions === "-" && deletions === "-";
const [before, after] = isBinaryFile
? ["", ""]
: yield* Effect.all(
[
git([
...GIT_CFG,
...gitArgs(["show", `${from}:${file}`]),
]).pipe(Effect.map((r) => r.text)),
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(
Effect.map((r) => r.text),
),
],
{ concurrency: 2 },
);
const added = isBinaryFile ? 0 : parseInt(additions!);
const deleted = isBinaryFile ? 0 : parseInt(deletions!);
result.push({
file: file!,
before,
after,
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file!) ?? "modified",
});
}
return result;
});
// Start hourly cleanup fiber — scoped to instance lifetime
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) });
return Effect.void;
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.forkScoped,
);
return SnapshotService.of({
init: Effect.fn("SnapshotService.init")(function* () {}),
cleanup,
track,
patch,
restore,
revert,
diff,
diffFull,
});
}),
).pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
);
}

View File

@@ -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",
})

View File

@@ -681,9 +681,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
// Give the background scan time to populate
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0)
@@ -697,8 +695,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "", type: "directory" })
expect(result.length).toBeGreaterThan(0)
@@ -718,8 +715,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
@@ -733,8 +729,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "", type: "file" })
// Files don't end with /
@@ -751,8 +746,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "", type: "directory" })
// Directories end with /
@@ -769,8 +763,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2)
@@ -784,8 +777,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
await File.init()
const result = await File.search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0)

View File

@@ -9,6 +9,19 @@ 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 = SessionID.make("ses_00000000000000000000000001")
@@ -25,7 +38,6 @@ describe("file/time", () => {
expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const after = await FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
@@ -44,7 +56,6 @@ describe("file/time", () => {
fn: async () => {
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
await Bun.sleep(10)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
@@ -63,14 +74,10 @@ describe("file/time", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
await FileTime.read(sessionID, filepath)
const first = await FileTime.get(sessionID, filepath)
await Bun.sleep(10)
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
await FileTime.read(sessionID, filepath)
const second = await FileTime.get(sessionID, filepath)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
@@ -84,12 +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)
await Bun.sleep(10)
await FileTime.read(sessionID, filepath)
await FileTime.assert(sessionID, filepath)
},
})
@@ -112,13 +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)
await Bun.sleep(100)
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")
},
})
@@ -128,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 () => {
await FileTime.read(sessionID, filepath)
await Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
let error: Error | undefined
try {
@@ -191,18 +200,25 @@ 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 Bun.sleep(50)
ready.open()
await hold.wait
order.push(2)
})
await ready.wait
const op2 = FileTime.withLock(filepath, async () => {
order.push(3)
order.push(4)
})
hold.open()
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
},
@@ -219,15 +235,21 @@ 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 Bun.sleep(50)
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])
@@ -265,12 +287,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)
await Bun.sleep(10)
await FileTime.read(sessionID, filepath)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
@@ -285,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 Bun.sleep(10)
await FileTime.read(sessionID, filepath)
const originalStat = Filesystem.stat(filepath)
await Bun.sleep(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())

View File

@@ -1,14 +1,14 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
import { InstanceContext } from "../../src/effect/instance-context"
import { Instance } from "../../src/project/instance"
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",
}),
)
ConfigProvider.fromUnknown({
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}),
);
/**
* Boot an Instance with the given service layers and run `body` with
@@ -19,29 +19,35 @@ export const watcherConfigLayer = ConfigProvider.layer(
* 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>[] },
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()
}
},
})
return Instance.provide({
directory,
fn: async () => {
const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({
directory: Instance.directory,
worktree: Instance.worktree,
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();
}
},
});
}

View File

@@ -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")

View File

@@ -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())

View File

@@ -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(
{

View File

@@ -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(