Compare commits

...

31 Commits

Author SHA1 Message Date
Kit Langton ba44d37ac0 chore(lockfile): update bun lock for platform-node 2026-03-16 19:51:11 -04:00
Kit Langton e2d4e60eab refactor(skill): effectify discovery loading 2026-03-16 19:38:13 -04:00
Kit Langton e21d892cff refactor(skill): effectify discovery service 2026-03-16 18:57:00 -04:00
Kit Langton 527956e3ec refactor(skill): effectify SkillService as scoped service
Convert Skill namespace to SkillService class with Effect.fn methods.
Scanning runs eagerly in layer constructor — needs lazy init to avoid
breaking unrelated tests (same pattern as FileService).
2026-03-16 16:18:44 -04:00
Kit Langton 69381f6aea refactor(file): effectify FileService as scoped service (#17845) 2026-03-16 16:18:39 -04:00
opencode-agent[bot] df6508530f chore: generate 2026-03-16 19:59:49 +00:00
Kit Langton 335356280c refactor(format): effectify FormatService as scoped service (#17675) 2026-03-16 15:58:36 -04:00
opencode-agent[bot] 03d84f49c2 chore: generate 2026-03-16 18:24:21 +00:00
Kit Langton 2cbdf04ec9 refactor(file-time): effectify FileTimeService with Semaphore locks (#17835) 2026-03-16 18:23:13 +00:00
opencode-agent[bot] 410fbd8a00 chore: generate 2026-03-16 18:00:18 +00:00
Kit Langton e5cbecf17c fix+refactor(vcs): fix HEAD filter bug and effectify VcsService (#17829) 2026-03-16 13:59:11 -04:00
opencode-agent[bot] ca3af5dc6a chore: generate 2026-03-16 17:19:44 +00:00
Kit Langton 9e740d9947 stack: effectify-file-watcher-service (#17827) 2026-03-16 13:18:40 -04:00
opencode-agent[bot] d4694d058c chore: generate 2026-03-16 16:56:12 +00:00
Kit Langton 469c3a4204 refactor(instance): move scoped services to LayerMap (#17544) 2026-03-16 12:55:14 -04:00
DS 4cb29967f6 fix(opencode): apply message transforms during compaction (#17823) 2026-03-16 11:32:53 -05:00
Johannes Loher e718db624f fix(core): consider code: context_length_exceeded as context overflow in API call errors (#17748) 2026-03-16 10:25:24 -05:00
Michal Šlesár 15b27e0d18 fix(app): agent switch should not reset thinking level (#17470) 2026-03-16 19:43:29 +05:30
Kit Langton c523aac586 fix(cli): scope active org labels to the active account (#16957) 2026-03-16 10:01:42 -04:00
Shoubhit Dash 51fcd04a70 Wrap question option descriptions instead of truncating (#17782) 2026-03-16 11:29:18 +00:00
Luke Parker 4d7cbdcbef fix(ci): workaround by using hoisted Bun linker on Windows (#17751) 2026-03-16 08:39:06 +00:00
Luke Parker 59c530cc6c fix(opencode): teach Kit's test what an ID is (#17745) 2026-03-16 18:08:27 +10:00
Jason Quense c2ca1494e5 fix(opencode): preserve prompt tool enables with empty agent permissions (#17064)
Co-authored-by: jquense <jquense@ramp.com>
2026-03-15 23:01:46 -05:00
opencode 4ee426ba54 release: v1.2.27 2026-03-16 02:33:48 +00:00
Aiden Cline 510374207d fix: vcs watcher if statement (#17673) 2026-03-15 19:20:39 -05:00
Erik Engervall aedbecedf7 docs: Add opencode-firecrawl to ecosystem documentation (#17672) 2026-03-15 18:56:24 -05:00
Frank 9c00669927 zen: update claude prices 2026-03-15 10:54:40 -04:00
David Hill b9f6b40e3a tweak(ui): remove open label (#17512) 2026-03-15 08:25:40 -05:00
Orlando Ascanio ad06d8f496 docs(es): fix Spanish intro page translation, grammar, and terminology (#17563) 2026-03-15 08:22:32 -05:00
Kit Langton 2fc06c5a17 chore(permission): delete legacy permission module (#17534)
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
publish / version (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:macos-latest platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:macos-latest platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
2026-03-14 20:56:52 -05:00
Kit Langton 52877d8765 fix(question): clean up pending entry on abort (#17533) 2026-03-15 00:49:49 +00:00
76 changed files with 3156 additions and 2356 deletions
+9 -1
View File
@@ -41,5 +41,13 @@ runs:
shell: bash
- name: Install dependencies
run: bun install
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash
+44 -17
View File
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -77,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -111,7 +111,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -138,7 +138,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -162,7 +162,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -186,7 +186,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -219,7 +219,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -250,7 +250,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -279,7 +279,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -295,7 +295,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.26",
"version": "1.2.27",
"bin": {
"opencode": "./bin/opencode",
},
@@ -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",
@@ -416,7 +417,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -440,7 +441,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.26",
"version": "1.2.27",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -451,7 +452,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -486,7 +487,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -532,7 +533,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"zod": "catalog:",
},
@@ -543,7 +544,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -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=="],
@@ -349,3 +349,25 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
await waitFooter(page, firstState)
})
})
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const updated = await chooseDifferentVariant(page)
const available = await agents(page)
const other = available.find((name) => name !== updated.agent)
test.skip(!other, "only one agent available")
if (!other) return
await choose(page, promptAgentSelector, other)
await waitFooter(page, { agent: other, variant: updated.variant })
await choose(page, promptAgentSelector, updated.agent)
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.26",
"version": "1.2.27",
"description": "",
"type": "module",
"exports": {
@@ -326,7 +326,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
@@ -339,7 +339,6 @@ export function SessionHeader() {
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button>
<DropdownMenu
gutter={4}
+3 -2
View File
@@ -192,10 +192,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: item.model,
variant: item.variant,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
const session = id()
if (session) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.26",
"version": "1.2.27",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.26",
"version": "1.2.27",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.26",
"version": "1.2.27",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.26",
"version": "1.2.27",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.26"
version = "1.2.27"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.26/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.26",
"version": "1.2.27",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+35
View File
@@ -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.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.26",
"version": "1.2.27",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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",
+7 -4
View File
@@ -11,6 +11,11 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
@@ -99,11 +104,10 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
@@ -132,11 +136,10 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
@@ -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",
) {}
@@ -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
View 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))
}
}
+9 -4
View File
@@ -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))))
}
+629 -557
View File
@@ -12,10 +12,263 @@ import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "file" })
const binaryExtensions = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const imageExtensions = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const textExtensions = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
function shouldEncode(mimeType: string): boolean {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export namespace File {
const log = Log.create({ service: "file" })
export const Info = z
.object({
path: z.string(),
@@ -73,256 +326,6 @@ export namespace File {
})
export type Content = z.infer<typeof Content>
const binaryExtensions = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const imageExtensions = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const textExtensions = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
async function shouldEncode(mimeType: string): Promise<boolean> {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export const Event = {
Edited: BusEvent.define(
"file.edited",
@@ -332,323 +335,392 @@ export namespace File {
),
}
const state = Instance.state(async () => {
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
let fetching = false
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const fn = async (result: Entry) => {
// Disable scanning if in root of file system
if (Instance.directory === path.parse(Instance.directory).root) return
fetching = true
if (isGlobalHome) {
const dirs = new Set<string>()
const ignore = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(Instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnore(entry.name)) continue
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[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
result.dirs = Array.from(dirs).toSorted()
cache = result
fetching = false
return
}
const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
result.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (set.has(dir)) continue
set.add(dir)
result.dirs.push(dir + "/")
}
}
cache = result
fetching = false
}
fn(cache)
return {
async files() {
if (!fetching) {
fn({
files: [],
dirs: [],
})
}
return cache
},
}
})
export function init() {
state()
return runPromiseInstance(FileService.use((s) => s.init()))
}
export async function status() {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changedFiles: Info[] = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Filesystem.readText(path.join(Instance.directory, filepath))
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
path: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path)
return {
...x,
path: path.relative(Instance.directory, full),
}
})
return runPromiseInstance(FileService.use((s) => s.status()))
}
export async function read(file: string): Promise<Content> {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
return { type: "text", content: "" }
}
const text = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !text) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = text ? false : await shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
return { type: "text", content, mimeType, encoding: "base64" }
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
const diff = formatPatch(patch)
return { type: "text", content, patch, diff }
}
}
return { type: "text", content }
return runPromiseInstance(FileService.use((s) => s.read(file)))
}
export async function list(dir?: string) {
const exclude = [".git", ".DS_Store"]
const project = Instance.project
let ignored = (_: string) => false
if (project.vcs === "git") {
const ig = ignore()
const gitignorePath = path.join(Instance.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignorePath))
}
const ignorePath = path.join(Instance.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignorePath))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
const nodes: Node[] = []
for (const entry of await fs.promises
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(Instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: relativePath,
absolute: fullPath,
type,
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return runPromiseInstance(FileService.use((s) => s.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = await state().then((x) => x.files())
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
return runPromiseInstance(FileService.use((s) => s.search(input)))
}
}
export namespace FileService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
readonly search: (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
}
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
static readonly layer = Layer.effect(
FileService,
Effect.gen(function* () {
const instance = yield* InstanceContext
// File cache state
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
let task: Promise<void> | undefined
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
function kick() {
if (task) return task
task = (async () => {
// Disable scanning if in root of file system
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
try {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
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[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (set.has(dir)) continue
set.add(dir)
next.dirs.push(dir + "/")
}
}
}
cache = next
} finally {
task = undefined
}
})()
return task
}
const getFiles = async () => {
void kick()
return cache
}
const init = Effect.fn("FileService.init")(function* () {
void kick()
})
const status = Effect.fn("FileService.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: instance.directory,
})
).text()
const changedFiles: File.Info[] = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Filesystem.readText(path.join(instance.directory, filepath))
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = (
await git(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
path: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
return {
...x,
path: path.relative(instance.directory, full),
}
})
})
})
const read = Effect.fn("FileService.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
return { type: "text", content: "" }
}
const text = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !text) {
return { type: "binary", content: "" }
}
if (!(await Filesystem.exists(full))) {
return { type: "text", content: "" }
}
const mimeType = Filesystem.mimeType(full)
const encode = text ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
}
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64")
return { type: "text", content, mimeType, encoding: "base64" }
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (instance.project.vcs === "git") {
let diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
).text()
}
if (diff.trim()) {
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
const diff = formatPatch(patch)
return { type: "text", content, patch, diff }
}
}
return { type: "text", content }
})
})
const list = Effect.fn("FileService.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignorePath))
}
const ignorePath = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignorePath))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
const nodes: File.Node[] = []
for (const entry of await fs.promises
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: relativePath,
absolute: fullPath,
type,
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("FileService.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
type?: "file" | "directory"
}) {
return yield* Effect.promise(async () => {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = await getFiles()
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
})
})
log.info("init")
return FileService.of({ init, status, read, list, search })
}),
)
}
+104 -60
View File
@@ -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)))
}
}
+121 -99
View File
@@ -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 }))
}),
),
)
}
+11 -3
View File
@@ -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")
+132 -111
View File
@@ -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 })
}),
)
}
-210
View File
@@ -1,210 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionID, MessageID } from "@/session/schema"
import z from "zod"
import { Log } from "../util/log"
import { Plugin } from "../plugin"
import { Instance } from "../project/instance"
import { Wildcard } from "../util/wildcard"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
function toKeys(pattern: Info["pattern"], type: string): string[] {
return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern]
}
function covered(keys: string[], approved: Map<string, boolean>): boolean {
return keys.every((k) => {
for (const p of approved.keys()) {
if (Wildcard.match(k, p)) return true
}
return false
})
}
export const Info = z
.object({
id: PermissionID.zod,
type: z.string(),
pattern: z.union([z.string(), z.array(z.string())]).optional(),
sessionID: SessionID.zod,
messageID: MessageID.zod,
callID: z.string().optional(),
message: z.string(),
metadata: z.record(z.string(), z.any()),
time: z.object({
created: z.number(),
}),
})
.meta({
ref: "Permission",
})
export type Info = z.infer<typeof Info>
interface PendingEntry {
info: Info
resolve: () => void
reject: (e: any) => void
}
export const Event = {
Updated: BusEvent.define("permission.updated", Info),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
permissionID: PermissionID.zod,
response: z.string(),
}),
),
}
const state = Instance.state(
() => ({
pending: new Map<SessionID, Map<PermissionID, PendingEntry>>(),
approved: new Map<SessionID, Map<string, boolean>>(),
}),
async (state) => {
for (const session of state.pending.values()) {
for (const item of session.values()) {
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata))
}
}
},
)
export function pending() {
return state().pending
}
export function list() {
const { pending } = state()
const result: Info[] = []
for (const session of pending.values()) {
for (const item of session.values()) {
result.push(item.info)
}
}
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)))
}
export async function ask(input: {
type: Info["type"]
message: Info["message"]
pattern?: Info["pattern"]
callID?: Info["callID"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
metadata: Info["metadata"]
}) {
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
messageID: input.messageID,
toolCallID: input.callID,
pattern: input.pattern,
})
const approvedForSession = approved.get(input.sessionID)
const keys = toKeys(input.pattern, input.type)
if (approvedForSession && covered(keys, approvedForSession)) return
const info: Info = {
id: PermissionID.ascending(),
type: input.type,
pattern: input.pattern,
sessionID: input.sessionID,
messageID: input.messageID,
callID: input.callID,
message: input.message,
metadata: input.metadata,
time: {
created: Date.now(),
},
}
switch (
await Plugin.trigger("permission.ask", info, {
status: "ask",
}).then((x) => x.status)
) {
case "deny":
throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata)
case "allow":
return
}
if (!pending.has(input.sessionID)) pending.set(input.sessionID, new Map())
return new Promise<void>((resolve, reject) => {
pending.get(input.sessionID)!.set(info.id, {
info,
resolve,
reject,
})
Bus.publish(Event.Updated, info)
})
}
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
log.info("response", input)
const { pending, approved } = state()
const session = pending.get(input.sessionID)
const match = session?.get(input.permissionID)
if (!session || !match) return
session.delete(input.permissionID)
if (session.size === 0) pending.delete(input.sessionID)
Bus.publish(Event.Replied, {
sessionID: input.sessionID,
permissionID: input.permissionID,
response: input.response,
})
if (input.response === "reject") {
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
return
}
match.resolve()
if (input.response === "always") {
if (!approved.has(input.sessionID)) approved.set(input.sessionID, new Map())
const approvedSession = approved.get(input.sessionID)!
const approveKeys = toKeys(match.info.pattern, match.info.type)
for (const k of approveKeys) {
approvedSession.set(k, true)
}
const items = pending.get(input.sessionID)
if (!items) return
const toRespond: Info[] = []
for (const item of items.values()) {
const itemKeys = toKeys(item.info.pattern, item.info.type)
if (covered(itemKeys, approvedSession)) {
toRespond.push(item.info)
}
}
for (const item of toRespond) {
respond({
sessionID: item.sessionID,
permissionID: item.id,
response: input.response,
})
}
}
}
export class RejectedError extends Error {
constructor(
public readonly sessionID: SessionID,
public readonly permissionID: PermissionID,
public readonly toolCallID?: string,
public readonly metadata?: Record<string, any>,
public readonly reason?: string,
) {
super(
reason !== undefined
? reason
: `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
)
}
}
}
+13 -22
View File
@@ -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 {
+21 -35
View File
@@ -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 })
+6 -5
View File
@@ -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()
+16 -7
View File
@@ -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
+53 -45
View File
@@ -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
}),
})
}),
)
}
+16 -41
View File
@@ -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,
})
}),
)
+5 -21
View File
@@ -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
+2 -1
View File
@@ -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,
+35 -31
View File
@@ -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
}
+5 -10
View File
@@ -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()))
}
}
+7 -11
View File
@@ -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 })
@@ -129,11 +123,15 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Deferred.await(deferred)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
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 })
@@ -150,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 })
@@ -166,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)
})
+3 -2
View File
@@ -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,
})
+3 -1
View File
@@ -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: [
+6 -2
View File
@@ -32,6 +32,7 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
permission?: PermissionNext.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
@@ -255,8 +256,11 @@ export namespace LLM {
})
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = PermissionNext.disabled(
Object.keys(input.tools),
PermissionNext.merge(input.agent.permission, input.permission ?? []),
)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool]
+2 -1
View File
@@ -666,6 +666,7 @@ export namespace SessionPrompt {
const result = await processor.process({
user: lastUser,
agent,
permission: session.permission,
abort,
sessionID,
system,
@@ -1244,7 +1245,7 @@ export namespace SessionPrompt {
]
}
FileTime.read(input.sessionID, filepath)
await FileTime.read(input.sessionID, filepath)
return [
{
messageID: info.id,
+111 -91
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),
)
}
+191 -142
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))
}
+2 -2
View File
@@ -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 = {
+1 -1
View File
@@ -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>`
+1 -1
View File
@@ -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" },
)
}
+466
View File
@@ -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,469 @@ 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 () => {
File.init()
// Give the background scan time to populate
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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 () => {
File.init()
await new Promise((r) => setTimeout(r, 500))
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()
},
})
})
})
})
+31 -84
View File
@@ -1,13 +1,16 @@
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())
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 +21,13 @@ 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)
await Bun.sleep(10)
const after = FileTime.get(sessionID, filepath)
const after = await FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0)
},
@@ -38,11 +42,12 @@ 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)
await Bun.sleep(10)
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()
@@ -59,14 +64,16 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
const first = FileTime.get(sessionID, filepath)!
await Bun.sleep(10)
const first = await FileTime.get(sessionID, filepath)
await new Promise((resolve) => setTimeout(resolve, 10))
await Bun.sleep(10)
FileTime.read(sessionID, filepath)
const second = FileTime.get(sessionID, filepath)!
await Bun.sleep(10)
const second = await FileTime.get(sessionID, filepath)
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
},
})
})
@@ -82,8 +89,7 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
// Should not throw
await Bun.sleep(10)
await FileTime.assert(sessionID, filepath)
},
})
@@ -111,13 +117,8 @@ describe("file/time", () => {
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 Bun.sleep(100)
await fs.writeFile(filepath, "modified content", "utf-8")
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
},
})
@@ -131,8 +132,8 @@ describe("file/time", () => {
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 Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8")
let error: Error | undefined
@@ -147,28 +148,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()", () => {
@@ -215,7 +194,7 @@ describe("file/time", () => {
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
await new Promise((resolve) => setTimeout(resolve, 10))
await Bun.sleep(50)
order.push(2)
})
@@ -225,12 +204,7 @@ describe("file/time", () => {
})
await Promise.all([op1, op2])
// Operations should be serialized
expect(order).toContain(1)
expect(order).toContain(2)
expect(order).toContain(3)
expect(order).toContain(4)
expect(order).toEqual([1, 2, 3, 4])
},
})
})
@@ -248,8 +222,8 @@ describe("file/time", () => {
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
await Bun.sleep(50)
expect(started2).toBe(true)
})
const op2 = FileTime.withLock(filepath2, async () => {
@@ -257,7 +231,6 @@ describe("file/time", () => {
})
await Promise.all([op1, op2])
expect(started1).toBe(true)
expect(started2).toBe(true)
},
@@ -277,7 +250,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 +258,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", () => {
@@ -323,12 +270,12 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
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)
},
})
@@ -343,11 +290,11 @@ describe("file/time", () => {
directory: tmp.path,
fn: async () => {
FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const originalStat = Filesystem.stat(filepath)
// Wait and modify
await new Promise((resolve) => setTimeout(resolve, 100))
await Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8")
const newStat = Filesystem.stat(filepath)
+236
View 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)
}),
),
),
)
})
})
@@ -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()
}
},
})
}
@@ -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()))
})
})
})
+14 -20
View File
@@ -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 },
)
+117
View 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()
+91 -1
View File
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import type { ModelMessage } from "ai"
import { tool, type ModelMessage } from "ai"
import z from "zod"
import { LLM } from "../../src/session/llm"
import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance"
@@ -325,6 +326,95 @@ describe("session.llm.stream", () => {
})
})
test("keeps tools enabled by prompt permissions", async () => {
const server = state.server
if (!server) {
throw new Error("Server not initialized")
}
const providerID = "alibaba"
const modelID = "qwen-plus"
const fixture = await loadFixture(providerID, modelID)
const model = fixture.model
const request = waitRequest(
"/chat/completions",
new Response(createChatStream("Hello"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
}),
)
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: [providerID],
provider: {
[providerID]: {
options: {
apiKey: "test-key",
baseURL: `${server.url.origin}/v1`,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
const sessionID = SessionID.make("session-test-tools")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "question", pattern: "*", action: "deny" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
tools: { question: true },
} satisfies MessageV2.User
const stream = await LLM.stream({
user,
sessionID,
model: resolved,
agent,
permission: [{ permission: "question", pattern: "*", action: "allow" }],
system: ["You are a helpful assistant."],
abort: new AbortController().signal,
messages: [{ role: "user", content: "Hello" }],
tools: {
question: tool({
description: "Ask a question",
inputSchema: z.object({}),
execute: async () => ({ output: "" }),
}),
},
})
for await (const _ of stream.fullStream) {
}
const capture = await request
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
},
})
})
test("sends responses API payload for OpenAI models", async () => {
const server = state.server
if (!server) {
@@ -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({
+18 -12
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())
@@ -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)
}),
),
)
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"scripts": {
+56 -56
View File
@@ -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
+177 -177
View File
@@ -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"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.26",
"version": "1.2.27",
"type": "module",
"license": "MIT",
"exports": {
+2 -12
View File
@@ -1050,18 +1050,8 @@
line-height: var(--line-height-large);
color: var(--text-base);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="question-option"][data-custom="true"] {
[data-slot="option-description"] {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
overflow-wrap: anywhere;
white-space: normal;
}
[data-slot="question-custom"] {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.26",
"version": "1.2.27",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.26",
"version": "1.2.27",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -49,6 +49,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness 16 components, one install |
| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
| [opencode-sentry-monitor](https://github.com/stolinski/opencode-sentry-monitor) | Trace and debug your AI agents with Sentry AI Monitoring |
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
---
+63 -77
View File
@@ -1,23 +1,23 @@
---
title: Introducción
description: Comience con OpenCode.
description: Comience a usar OpenCode.
---
import { Tabs, TabItem } from "@astrojs/starlight/components"
import config from "../../../../config.mjs"
export const console = config.console
[**OpenCode**](/) es un agente de codificación de IA de código abierto. Está disponible como interfaz basada en terminal, aplicación de escritorio o extensión IDE.
[**OpenCode**](/) es un agente de codigo de IA de código abierto. Está disponible como interfaz basada en terminal, aplicación de escritorio o extensión IDE.
![OpenCode TUI con el tema opencode](../../../assets/lander/screenshot.png)
Empecemos.
Comencemos.
---
#### Requisitos previos
Para usar OpenCode en su terminal, necesitará:
Para usar OpenCode en la terminal, necesitará:
1. Un emulador de terminal moderno como:
- [WezTerm](https://wezterm.org), multiplataforma
@@ -25,7 +25,7 @@ Para usar OpenCode en su terminal, necesitará:
- [Ghostty](https://ghostty.org), Linux y macOS
- [Kitty](https://sw.kovidgoyal.net/kitty/), Linux y macOS
2. Claves API para los LLM proveedores que desea utilizar.
2. Claves de API de los proveedores de LLM que quiera usar.
---
@@ -37,7 +37,7 @@ La forma más sencilla de instalar OpenCode es mediante el script de instalació
curl -fsSL https://opencode.ai/install | bash
```
También puedes instalarlo con los siguientes comandos:
También puedes instalarlo con alguno de los siguientes métodos:
- **Usando Node.js**
@@ -91,7 +91,7 @@ También puedes instalarlo con los siguientes comandos:
#### Windows
:::tip[Recomendado: Usar WSL]
Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Proporciona un mejor rendimiento y compatibilidad total con las funciones de OpenCode.
Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Ofrece mejor rendimiento y compatibilidad total con las funciones de OpenCode.
:::
- **Usando Chocolatey**
@@ -124,28 +124,28 @@ Para obtener la mejor experiencia en Windows, recomendamos utilizar [Windows Sub
docker run -it --rm ghcr.io/anomalyco/opencode
```
Actualmente se encuentra en progreso el soporte para instalar OpenCode en Windows usando Bun.
El soporte para instalar OpenCode en Windows usando Bun todavía está en desarrollo.
También puede obtener el binario de [Versiones](https://github.com/anomalyco/opencode/releases).
También puede obtener el binario desde [Versiones](https://github.com/anomalyco/opencode/releases).
---
## Configuración
Con OpenCode puedes usar cualquier proveedor LLM configurando sus claves API.
Con OpenCode, puede usar cualquier proveedor de LLM configurando sus claves de API.
Si es nuevo en el uso de proveedores LLM, le recomendamos usar [OpenCode Zen](/docs/zen).
Es una lista seleccionada de modelos que han sido probados y verificados por el equipo de OpenCode.
Si es nuevo en el uso de proveedores de LLM, le recomendamos usar [OpenCode Zen](/docs/zen).
Es una selección de modelos probados y verificados por el equipo de OpenCode.
1. Ejecute el comando `/connect` en TUI, seleccione opencode y diríjase a [opencode.ai/auth](https://opencode.ai/auth).
1. Ejecute el comando `/connect` en la TUI, seleccione opencode y diríjase a [opencode.ai/auth](https://opencode.ai/auth).
```txt
/connect
```
2. Inicie sesión, agregue sus datos de facturación y copie su clave API.
2. Inicie sesión, agregue sus datos de facturación y copie su clave de API.
3. Pega tu clave API.
3. Pega tu clave de API.
```txt
┌ API key
@@ -154,50 +154,45 @@ Es una lista seleccionada de modelos que han sido probados y verificados por el
└ enter
```
Alternativamente, puede seleccionar uno de los otros proveedores. [Más información](/docs/providers#directory).
También puede seleccionar otro proveedor. [Más información](/docs/providers#directory).
---
## Inicializar
Ahora que ha configurado un proveedor, puede navegar a un proyecto que
quieres trabajar.
Ahora que ya configuró un proveedor, vaya al proyecto en el que quiera trabajar.
```bash
cd /path/to/project
```
Y ejecute OpenCode.
Luego, ejecute OpenCode.
```bash
opencode
```
A continuación, inicialice OpenCode para el proyecto ejecutando el siguiente comando.
A continuación, inicialice OpenCode para el proyecto con el siguiente comando:
```bash frame="none"
/init
```
Esto hará que OpenCode analice su proyecto y cree un archivo `AGENTS.md` en
la raíz del proyecto.
OpenCode analizará su proyecto y creará un archivo AGENTS.md en la raíz.
:::tip
Debes enviar el archivo `AGENTS.md` de tu proyecto a Git.
Asegúrese de versionar en Git el archivo AGENTS.md de su proyecto.
:::
Esto ayuda a OpenCode a comprender la estructura del proyecto y los patrones de codificación.
usado.
Esto ayuda a OpenCode a comprender la estructura del proyecto y los patrones de código que se usan en él.
---
## Usar
Ahora está listo para usar OpenCode para trabajar en su proyecto. No dudes en preguntarle
¡cualquier cosa!
Ahora ya está listo para usar OpenCode en su proyecto. Puede pedirle desde explicaciones del código hasta cambios concretos.
Si es nuevo en el uso de un agente de codificación de IA, aquí hay algunos ejemplos que podrían
ayuda.
Si es la primera vez que usa un agente de codigo con IA, estos ejemplos pueden servirle como punto de partida.
---
@@ -206,126 +201,117 @@ ayuda.
Puede pedirle a OpenCode que le explique el código base.
:::tip
Utilice la tecla `@` para realizar una búsqueda aproximada de archivos en el proyecto.
Utilice la tecla `@` para realizar una búsqueda aproximada de archivos dentro del proyecto.
:::
```txt frame="none" "@packages/functions/src/api/index.ts"
How is authentication handled in @packages/functions/src/api/index.ts
¿Cómo se maneja la autenticación en @packages/functions/src/api/index.ts
```
Esto es útil si hay una parte del código base en la que no trabajaste.
Esto resulta útil cuando hay una parte del código base en la que usted no ha trabajado.
---
### Agregar funciones
### Agregar funcionalidades
Puede pedirle a OpenCode que agregue nuevas funciones a su proyecto. Aunque primero recomendamos pedirle que cree un plan.
Puede pedirle a OpenCode que agregue nuevas funcionalidades a su proyecto. Aun así, primero recomendamos pedirle que cree un plan.
1. **Crea un plan**
1. **Crear un plan**
OpenCode tiene un _Modo Plan_ que desactiva su capacidad para realizar cambios y
en su lugar, sugiera _cómo_ implementará la función.
OpenCode tiene un modo Plan que desactiva temporalmente su capacidad de hacer cambios y, en su lugar, propone _cómo_ implementará la funcionalidad.
Cambie a él usando la tecla **Tab**. Verás un indicador para esto en la esquina inferior derecha.
Cambie a este modo con la tecla **Tab.** Verá un indicador en la esquina inferior derecha.
```bash frame="none" title="Switch to Plan mode"
<TAB>
```
Ahora describamos lo que queremos que haga.
Ahora describa lo que quiere que haga.
```txt frame="none"
When a user deletes a note, we'd like to flag it as deleted in the database.
Then create a screen that shows all the recently deleted notes.
From this screen, the user can undelete a note or permanently delete it.
Cuando un usuario elimine una nota, queremos marcarla como eliminada en la base de datos.
Luego, cree una pantalla que muestre todas las notas eliminadas recientemente.
Desde esa pantalla, el usuario podrá restaurar una nota o eliminarla de forma permanente.
```
Quiere darle a OpenCode suficientes detalles para entender lo que quiere. ayuda
hablar con él como si estuviera hablando con un desarrollador junior de su equipo.
Procure darle a OpenCode suficiente contexto para que entienda exactamente lo que necesita. Ayuda hablarle como si estuviera hablando con un desarrollador junior de su equipo.
:::tip
Dale a OpenCode mucho contexto y ejemplos para ayudarlo a comprender lo que
desear.
Déle a OpenCode todo el contexto y los ejemplos que pueda para ayudarle a comprender lo que desea.
:::
2. **Repetir el plan**
2. **Iterar sobre el plan**
Una vez que le proporcione un plan, puede enviarle comentarios o agregar más detalles.
Una vez que OpenCode le proponga un plan, puede darle comentarios o agregar más detalles.
```txt frame="none"
We'd like to design this new screen using a design I've used before.
[Image #1] Take a look at this image and use it as a reference.
Queremos diseñar esta nueva pantalla usando un diseño que ya hemos usado antes.
[Imagen #1] Revise esta imagen y úsela como referencia.
```
:::tip
Arrastre y suelte imágenes en la terminal para agregarlas al mensaje.
:::
OpenCode puede escanear cualquier imagen que le proporcione y agregarla al mensaje. Puede
Haga esto arrastrando y soltando una imagen en la terminal.
OpenCode puede analizar cualquier imagen que usted le proporcione y añadirla al contexto del mensaje. Puede hacerlo arrastrando y soltando una imagen en la terminal.
3. **Crea la función**
3. **Implementar la funcionalidad**
Una vez que se sienta cómodo con el plan, vuelva al _Modo Build_
presionando la tecla **Tab** nuevamente.
Cuando esté conforme con el plan, vuelva al modo _Build_ presionando de nuevo la tecla Tab.
```bash frame="none"
<TAB>
```
Y pidiéndole que haga los cambios.
Luego, pídale que haga los cambios.
```bash frame="none"
Sounds good! Go ahead and make the changes.
Perfecto. Continúe y realice los cambios.
```
---
### Realizar cambios
Para cambios más sencillos, puede pedirle a OpenCode que lo construya directamente.
sin tener que revisar el plan primero.
Para cambios más sencillos, puede pedirle a OpenCode que los implemente directamente, sin revisar antes un plan.
```txt frame="none" "@packages/functions/src/settings.ts" "@packages/functions/src/notes.ts"
We need to add authentication to the /settings route. Take a look at how this is
handled in the /notes route in @packages/functions/src/notes.ts and implement
the same logic in @packages/functions/src/settings.ts
Necesitamos agregar autenticación a la ruta /settings. Revise cómo se maneja esto
en la ruta /notes en @packages/functions/src/notes.ts e implemente
la misma lógica en @packages/functions/src/settings.ts.
```
Desea asegurarse de proporcionar una buena cantidad de detalles para que OpenCode tome la decisión correcta.
cambios.
Procure dar suficientes detalles para que OpenCode pueda tomar las decisiones correctas al hacer los cambios
---
### Deshacer cambios
Digamos que le pides a OpenCode que haga algunos cambios.
Supongamos que le pide a OpenCode que haga algunos cambios.
```txt frame="none" "@packages/functions/src/api/index.ts"
Can you refactor the function in @packages/functions/src/api/index.ts?
¿Puede refactorizar la funcn en @packages/functions/src/api/index.ts?
```
Pero te das cuenta de que no es lo que querías. Puedes **deshacer** los cambios
usando el comando `/undo`.
Pero luego se da cuenta de que no era lo que quería. Puede **deshacer** los cambios usando el comando `/undo`.
```bash frame="none"
/undo
```
OpenCode ahora revertirá los cambios que realizó y mostrará su mensaje original
de nuevo.
OpenCode revertirá los cambios que hizo y volverá a mostrar su mensaje original.
```txt frame="none" "@packages/functions/src/api/index.ts"
Can you refactor the function in @packages/functions/src/api/index.ts?
¿Puede refactorizar la funcn en @packages/functions/src/api/index.ts?
```
Desde aquí puedes modificar el mensaje y pedirle a OpenCode que vuelva a intentarlo.
Desde ahí, puede modificar el mensaje y pedirle a OpenCode que lo intente de nuevo.
:::tip
Puede ejecutar `/undo` varias veces para deshacer varios cambios.
:::
O **puedes rehacer** los cambios usando el comando `/redo`.
También puede rehacer los cambios usando el comando `/redo.`
```bash frame="none"
/redo
@@ -335,7 +321,7 @@ O **puedes rehacer** los cambios usando el comando `/redo`.
## Compartir
Las conversaciones que tengas con OpenCode pueden ser [compartidas con tu
Las conversaciones que tenga con OpenCode pueden [compartirse con su
equipo](/docs/share).
```bash frame="none"
@@ -348,12 +334,12 @@ Esto creará un enlace a la conversación actual y lo copiará en su portapapele
Las conversaciones no se comparten de forma predeterminada.
:::
Aquí hay una [conversación de ejemplo](https://opencode.ai/s/4XP1fce5) con OpenCode.
Aquí tiene una [conversación de ejemplo](https://opencode.ai/s/4XP1fce5) con OpenCode.
---
## Personalizar
¡Y eso es todo! Ahora eres un profesional en el uso de OpenCode.
Y eso es todo. Ya conoce lo básico para empezar a usar OpenCode.
Para personalizarlo, recomendamos [elegir un tema](/docs/themes), [personalizar las combinaciones de teclas](/docs/keybinds), [configurar formateadores de código](/docs/formatters), [crear comandos personalizados](/docs/commands) o jugar con la [configuración OpenCode](/docs/config).
Para personalizarlo, recomendamos [elegir un tema](/docs/themes), [personalizar las combinaciones de teclas](/docs/keybinds), [configurar formateadores de código](/docs/formatters), [crear comandos personalizados](/docs/commands) o explorar la [configuración OpenCode](/docs/config).
+2 -4
View File
@@ -137,12 +137,10 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.26",
"version": "1.2.27",
"publisher": "sst-dev",
"repository": {
"type": "git",