Compare commits

..
Author SHA1 Message Date
Adam 2d65db95af fix(app): startup homepage flash 2026-03-13 09:50:37 -05:00
139 changed files with 3710 additions and 5975 deletions
+1 -9
View File
@@ -41,13 +41,5 @@ runs:
shell: bash shell: bash
- name: Install dependencies - name: Install dependencies
run: | run: bun install
# 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 shell: bash
+17 -44
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -77,7 +77,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -111,7 +111,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -138,7 +138,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -162,7 +162,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -186,7 +186,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -219,7 +219,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -250,7 +250,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -279,7 +279,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -295,7 +295,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.2.27", "version": "1.2.25",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -324,7 +324,6 @@
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "4.0.0-beta.31",
"@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
@@ -417,7 +416,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -441,7 +440,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.2.27", "version": "1.2.25",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -452,7 +451,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -487,7 +486,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -533,7 +532,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -544,7 +543,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -973,10 +972,6 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], "@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/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=="], "@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=="],
@@ -1173,8 +1168,6 @@
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], "@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/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=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
@@ -2543,8 +2536,6 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "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=="], "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=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
@@ -3185,8 +3176,6 @@
"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=="], "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=="], "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=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -3419,14 +3408,10 @@
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "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.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "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.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
@@ -3613,7 +3598,7 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
@@ -4037,10 +4022,6 @@
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "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=="], "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=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -4299,8 +4280,6 @@
"stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], "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=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -5007,16 +4986,12 @@
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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=="],
@@ -5057,8 +5032,6 @@
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@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-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-3
View File
@@ -95,9 +95,6 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
const win = window as E2EWindow const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
model: {
enabled: true,
},
terminal: { terminal: {
enabled: true, enabled: true,
terminals: {}, terminals: {},
-3
View File
@@ -13,9 +13,6 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
export const promptModelSelector = '[data-component="prompt-model-control"]'
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]' export const settingsThemeSelector = '[data-action="settings-theme"]'
@@ -1,373 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
} from "../selectors"
import { createSdk, sessionPath } from "../utils"
type Footer = {
agent: string
model: string
variant: string
}
type Probe = {
dir?: string
sessionID?: string
model?: { providerID: string; modelID: string }
}
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
}
}
async function waitFooter(page: Page, expected: Partial<Footer>) {
let hit: Footer | null = null
await expect
.poll(
async () => {
const state = await read(page)
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
if (ok) hit = state
return ok
},
{ timeout: 30_000 },
)
.toBe(true)
if (!hit) throw new Error("Failed to resolve prompt footer state")
return hit
}
async function waitModel(page: Page, value: string) {
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
}
async function choose(page: Page, root: string, value: string) {
const select = page.locator(root)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const item = page
.locator('[data-slot="select-select-item"]')
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
.first()
await expect(item).toBeVisible()
await item.click()
}
async function variantCount(page: Page) {
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const count = await page.locator('[data-slot="select-select-item"]').count()
await page.keyboard.press("Escape")
return count
}
async function agents(page: Page) {
const select = page.locator(promptAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
await page.keyboard.press("Escape")
return labels.map((item) => item.trim()).filter(Boolean)
}
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
const current = await read(page)
if ((await variantCount(page)) >= 2) return current
const cfg = await createSdk(directory)
.config.get()
.then((x) => x.data)
const visible = new Set(await agents(page))
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
const value = item[1]
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
})
const name = entry?.[0]
test.skip(!name, "no agent with alternate variants available")
if (!name) return current
await choose(page, promptAgentSelector, name)
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
return waitFooter(page, { agent: name })
}
async function chooseDifferentVariant(page: Page): Promise<Footer> {
const current = await read(page)
const select = page.locator(promptVariantSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
if (count < 2) throw new Error("Current model has no alternate variant to select")
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
if (!next || next === current.variant) continue
await item.click()
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
}
throw new Error("Failed to choose a different variant")
}
async function chooseOtherModel(page: Page): Promise<Footer> {
const current = await read(page)
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
await expect(button).toBeVisible()
await button.click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const items = dialog.locator('[data-slot="list-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
for (let i = 0; i < count; i++) {
const item = items.nth(i)
const selected = (await item.getAttribute("data-selected")) === "true"
if (selected) continue
await item.click()
await expect(dialog).toHaveCount(0)
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
return read(page)
}
throw new Error("Failed to choose a different model")
}
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
}
async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
return id
}
async function waitUser(directory: string, sessionID: string) {
const sdk = createSdk(directory)
await expect
.poll(
async () => {
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
return items.some((item) => item.info.role === "user")
},
{ timeout: 30_000 },
)
.toBe(true)
await sdk.session.abort({ sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
}
async function waitWorkspace(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
async function newWorkspaceSession(page: Page, slug: string) {
await waitWorkspace(page, slug)
const item = page.locator(workspaceItemSelector(slug)).first()
await item.hover()
const button = page.locator(workspaceNewSessionSelector(slug)).first()
await expect(button).toBeVisible()
await button.click({ force: true })
const next = await waitSlug(page)
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
}
test("session model and variant restore per session without leaking into new sessions", async ({
page,
withProject,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory, gotoSession, trackSession }) => {
await gotoSession()
await ensureVariant(page, directory)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `session variant ${Date.now()}`)
trackSession(first)
await waitUser(directory, first)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await waitFooter(page, firstState)
await gotoSession()
const fresh = await ensureVariant(page, directory)
expect(fresh.variant).not.toBe(firstState.variant)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `session model ${Date.now()}`)
trackSession(second)
await waitUser(directory, second)
await goto(page, directory, first)
await waitFooter(page, firstState)
await goto(page, directory, second)
await waitFooter(page, secondState)
await gotoSession()
await waitFooter(page, fresh)
})
})
test("session model restore across workspaces", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
await gotoSession()
await ensureVariant(page, root)
const firstState = await chooseDifferentVariant(page)
const first = await submit(page, `root session ${Date.now()}`)
trackSession(first, root)
await waitUser(root, first)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const one = await createWorkspace(page, slug, [])
const oneDir = await newWorkspaceSession(page, one.slug)
trackDirectory(oneDir)
const secondState = await chooseOtherModel(page)
const second = await submit(page, `workspace one ${Date.now()}`)
trackSession(second, oneDir)
await waitUser(oneDir, second)
const two = await createWorkspace(page, slug, [one.slug])
const twoDir = await newWorkspaceSession(page, two.slug)
trackDirectory(twoDir)
await ensureVariant(page, twoDir)
const thirdState = await chooseDifferentVariant(page)
const third = await submit(page, `workspace two ${Date.now()}`)
trackSession(third, twoDir)
await waitUser(twoDir, third)
await goto(page, root, first)
await waitFooter(page, firstState)
await goto(page, oneDir, second)
await waitFooter(page, secondState)
await goto(page, twoDir, third)
await waitFooter(page, thirdState)
await goto(page, root, first)
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", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.2.25",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+4 -3
View File
@@ -66,7 +66,6 @@ export const DialogFork: Component = () => {
directory: sdk.directory, directory: sdk.directory,
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
const dir = base64Encode(sdk.directory)
sdk.client.session sdk.client.session
.fork({ sessionID, messageID: item.id }) .fork({ sessionID, messageID: item.id })
@@ -76,8 +75,10 @@ export const DialogFork: Component = () => {
return return
} }
dialog.close() dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id }) navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
navigate(`/${dir}/session/${forked.data.id}`) requestAnimationFrame(() => {
prompt.set(restored)
})
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -13,10 +13,8 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
type ModelState = ReturnType<typeof useLocal>["model"] export const DialogSelectModelUnpaid: Component = () => {
const local = useLocal()
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
const model = props.model ?? useLocal().model
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const language = useLanguage() const language = useLanguage()
@@ -37,8 +35,8 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={model.list} items={local.model.list}
current={model.current()} current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => ( itemWrapper={(item, node) => (
<Tooltip <Tooltip
@@ -57,7 +55,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
dialog.close() dialog.close()
@@ -18,22 +18,19 @@ import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
onSelect: () => void onSelect: () => void
action?: JSX.Element action?: JSX.Element
model?: ModelState
}> = (props) => { }> = (props) => {
const model = props.model ?? useLocal().model const local = useLocal()
const language = useLanguage() const language = useLanguage()
const models = createMemo(() => const models = createMemo(() =>
model local.model
.list() .list()
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)), .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
) )
@@ -44,7 +41,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
items={models} items={models}
current={model.current()} current={local.model.current()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name} groupBy={(x) => x.provider.name}
@@ -66,7 +63,7 @@ const ModelList: Component<{
</Tooltip> </Tooltip>
)} )}
onSelect={(x) => { onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true, recent: true,
}) })
props.onSelect() props.onSelect()
@@ -91,7 +88,6 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: { export function ModelSelectorPopover(props: {
provider?: string provider?: string
model?: ModelState
children?: JSX.Element children?: JSX.Element
triggerAs?: ValidComponent triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps triggerProps?: ModelSelectorTriggerProps
@@ -155,7 +151,6 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title> <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList <ModelList
provider={props.provider} provider={props.provider}
model={props.model}
onSelect={() => setStore("open", false)} onSelect={() => setStore("open", false)}
class="p-1" class="p-1"
action={ action={
@@ -189,7 +184,7 @@ export function ModelSelectorPopover(props: {
) )
} }
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
@@ -207,7 +202,7 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
</Button> </Button>
} }
> >
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} /> <ModelList provider={props.provider} onSelect={() => dialog.close()} />
<Button <Button
variant="ghost" variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start" class="ml-3 mt-5 mb-6 text-text-base self-start"
+128 -131
View File
@@ -2,6 +2,7 @@ import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { import {
@@ -120,7 +121,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 52
const space = `${inset}px` const space = `${inset}px`
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
@@ -410,6 +411,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click() const pick = () => fileInputRef?.click()
@@ -1012,6 +1014,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => { focusEditor: () => {
@@ -1028,17 +1031,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
)
const toggleAccept = () => {
if (!params.id) {
permission.toggleAutoAcceptDirectory(sdk.directory)
return
}
permission.toggleAutoAccept(params.id, sdk.directory)
}
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
info, info,
@@ -1345,7 +1337,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}} }}
/> />
<div class="flex items-center gap-1 pointer-events-auto"> <div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip <Tooltip
placement="top" placement="top"
inactive={!prompt.dirty() && !working()} inactive={!prompt.dirty() && !working()}
@@ -1382,30 +1400,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
<div class="pointer-events-none absolute bottom-2 left-2"> <div class="pointer-events-none absolute bottom-2 left-2">
<div <div class="pointer-events-auto">
aria-hidden={store.mode !== "normal"}
class="pointer-events-auto"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
title={language.t("prompt.action.attachFile")} gutter={8}
keybind={command.keybind("file.attach")} title={language.t(
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
)}
keybind={command.keybind("permissions.autoaccept")}
> >
<Button <Button
data-action="prompt-attach" data-action="prompt-permissions"
type="button"
variant="ghost" variant="ghost"
class="size-8 p-0" onClick={() => {
style={buttons()} if (!params.id) {
onClick={pick} permission.toggleAutoAcceptDirectory(sdk.directory)
disabled={store.mode !== "normal"} return
tabIndex={store.mode === "normal" ? undefined : -1} }
aria-label={language.t("prompt.action.attachFile")} permission.toggleAutoAccept(params.id, sdk.directory)
}}
classList={{
"size-6 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
aria-label={
accepting()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={accepting()}
> >
<Icon name="plus" class="size-4.5" /> <Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": accepting() }}
/>
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
@@ -1427,76 +1457,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<div data-component="prompt-agent-control"> <TooltipKeybind
<TooltipKeybind placement="top"
placement="top" gutter={4}
gutter={4} title={language.t("command.agent.cycle")}
title={language.t("command.agent.cycle")} keybind={command.keybind("agent.cycle")}
keybind={command.keybind("agent.cycle")} >
> <Select
<Select size="normal"
size="normal" options={agentNames()}
options={agentNames()} current={local.agent.current()?.name ?? ""}
current={local.agent.current()?.name ?? ""} onSelect={local.agent.set}
onSelect={local.agent.set} class="capitalize max-w-[160px]"
class="capitalize max-w-[160px] text-text-base" valueClass="truncate text-13-regular"
valueClass="truncate text-13-regular text-text-base" triggerStyle={control()}
triggerStyle={control()} variant="ghost"
triggerProps={{ "data-action": "prompt-agent" }} />
variant="ghost" </TooltipKeybind>
/> <Show
</TooltipKeybind> when={providers.paid().length > 0}
</div> fallback={
<div data-component="prompt-model-control">
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.choose")} title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")} keybind={command.keybind("model.choose")}
> >
<ModelSelectorPopover <Button
model={local.model} as="div"
triggerAs={Button} variant="ghost"
triggerProps={{ size="normal"
variant: "ghost", class="min-w-0 max-w-[320px] text-13-regular group"
size: "normal", style={control()}
style: control(), onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
@@ -1509,52 +1502,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover> </Button>
</TooltipKeybind> </TooltipKeybind>
</Show> }
</div> >
<div data-component="prompt-variant-control">
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
title={language.t("command.model.variant.cycle")} title={language.t("command.model.choose")}
keybind={command.keybind("model.variant.cycle")} keybind={command.keybind("model.choose")}
> >
<Select <ModelSelectorPopover
size="normal" triggerAs={Button}
options={variants()} triggerProps={{
current={local.model.variant.current() ?? "default"} variant: "ghost",
label={(x) => (x === "default" ? language.t("common.default") : x)} size: "normal",
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} style: control(),
class="capitalize max-w-[160px] text-text-base" class: "min-w-0 max-w-[320px] text-13-regular group",
valueClass="truncate text-13-regular text-text-base" }}
triggerStyle={control()} >
triggerProps={{ "data-action": "prompt-model-variant" }} <Show when={local.model.current()?.provider?.id}>
variant="ghost" <ProviderIcon
/> id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind> </TooltipKeybind>
</div> </Show>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={8} gutter={4}
title={acceptLabel()} title={language.t("command.model.variant.cycle")}
keybind={command.keybind("permissions.autoaccept")} keybind={command.keybind("model.variant.cycle")}
> >
<Button <Select
data-action="prompt-permissions" size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
variant="ghost" variant="ghost"
onClick={toggleAccept} />
classList={{
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
"text-text-base": !accepting(),
"hover:bg-surface-success-base": accepting(),
}}
style={control()}
aria-label={acceptLabel()}
aria-pressed={accepting()}
>
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
</Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { pasteMode } from "./paste"
describe("attachmentMime", () => { describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => { test("keeps PDFs when the browser reports the mime", async () => {
@@ -23,22 +22,3 @@ describe("attachmentMime", () => {
expect(await attachmentMime(file)).toBeUndefined() expect(await attachmentMime(file)).toBeUndefined()
}) })
}) })
describe("pasteMode", () => {
test("uses native paste for short single-line text", () => {
expect(pasteMode("hello world")).toBe("native")
})
test("uses manual paste for multiline text", () => {
expect(
pasteMode(`{
"ok": true
}`),
).toBe("manual")
expect(pasteMode("a\r\nb")).toBe("manual")
})
test("uses manual paste for large text", () => {
expect(pasteMode("x".repeat(8000))).toBe("manual")
})
})
@@ -5,7 +5,8 @@ import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom" import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files" import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste" const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function dataUrl(file: File, mime: string) { function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => { return new Promise<string>((resolve) => {
@@ -24,8 +25,20 @@ function dataUrl(file: File, mime: string) {
}) })
} }
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
type PromptAttachmentsInput = { type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean isDialogActive: () => boolean
setDraggingType: (type: "image" | "@mention" | null) => void setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void focusEditor: () => void
@@ -78,6 +91,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!input.isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
@@ -112,23 +126,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (!plainText) return if (!plainText) return
const text = normalizePaste(plainText) if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
const put = () => {
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
input.focusEditor() input.focusEditor()
return input.addPart({ type: "text", content: text, start: 0, end: 0 }) if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
} }
if (pasteMode(text) === "manual") { const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
put()
return
}
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
if (inserted) return if (inserted) return
put() input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
const handleGlobalDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
@@ -1,24 +0,0 @@
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
for (const char of text) {
if (char !== "\n") continue
breaks += 1
if (breaks >= LARGE_PASTE_BREAKS) return true
}
return false
}
export function normalizePaste(text: string) {
if (!text.includes("\r")) return text
return text.replace(/\r\n?/g, "\n")
}
export function pasteMode(text: string) {
if (largePaste(text)) return "manual"
if (text.includes("\n") || text.includes("\r")) return "manual"
return "native"
}
@@ -17,7 +17,6 @@ const optimistic: Array<{
}> = [] }> = []
const optimisticSeeded: boolean[] = [] const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = [] const sentShell: string[] = []
const syncedDirectories: string[] = [] const syncedDirectories: string[] = []
@@ -87,11 +86,6 @@ beforeAll(async () => {
agent: { agent: {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
},
}), }),
})) }))
@@ -207,7 +201,6 @@ beforeEach(() => {
enabledAutoAccept.length = 0 enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0 optimisticSeeded.length = 0
promoted.length = 0
params = {} params = {}
sentShell.length = 0 sentShell.length = 0
syncedDirectories.length = 0 syncedDirectories.length = 0
@@ -247,11 +240,6 @@ describe("prompt submit worktree selection", () => {
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("applies auto-accept to newly created sessions", async () => { test("applies auto-accept to newly created sessions", async () => {
@@ -296,7 +296,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current() const currentModel = local.model.current()
const currentAgent = local.agent.current() const currentAgent = local.agent.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) { if (!currentModel || !currentAgent) {
showToast({ showToast({
title: language.t("prompt.toast.modelAgentRequired.title"), title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -371,7 +370,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
seed(sessionDirectory, created) seed(sessionDirectory, created)
session = created session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
@@ -389,6 +387,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id, providerID: currentModel.provider.id,
} }
const agent = currentAgent.name const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice() const context = prompt.context.items().slice()
const draft: FollowupDraft = { const draft: FollowupDraft = {
sessionID: session.id, sessionID: session.id,
@@ -16,11 +16,9 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers" import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -134,7 +132,6 @@ export function SessionHeader() {
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const { params, view } = useSessionLayout() const { params, view } = useSessionLayout()
@@ -221,9 +218,6 @@ export function SessionHeader() {
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
) )
const opening = createMemo(() => openRequest.app !== undefined) const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const selectApp = (app: OpenApp) => { const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return if (!options().some((item) => item.id === app)) return
@@ -326,7 +320,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"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default" class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{ classList={{
"bg-surface-raised-base-active": opening(), "bg-surface-raised-base-active": opening(),
}} }}
@@ -336,9 +330,10 @@ export function SessionHeader() {
> >
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5"> <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}> <Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} /> <Spinner class="size-3.5 text-icon-base" />
</Show> </Show>
</div> </div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button> </Button>
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
+17 -40
View File
@@ -217,49 +217,26 @@ export function Titlebar() {
</TooltipKeybind> </TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0"> <div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}> <Show when={params.dir}>
<div <TooltipKeybind
class="flex items-center shrink-0 w-8 mr-1" placement="bottom"
aria-hidden={layout.sidebar.opened() ? "true" : undefined} title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
> >
<div <Button
class="transition-opacity" variant="ghost"
classList={{ icon={creating() ? "new-session-active" : "new-session"}
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(), class="titlebar-icon w-8 h-6 p-0 box-border"
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(), onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}} }}
> aria-label={language.t("command.session.new")}
<TooltipKeybind aria-current={creating() ? "page" : undefined}
placement="bottom" />
title={language.t("command.session.new")} </TooltipKeybind>
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show> </Show>
<div <div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
class="flex items-center gap-0 transition-transform"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button <Button
variant="ghost" variant="ghost"
+204 -374
View File
@@ -1,422 +1,252 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useModels } from "@/context/models" import { batch, createMemo } from "solid-js"
import { useProviders } from "@/hooks/use-providers" import { createSimpleContext } from "@opencode-ai/ui/context"
import { modelEnabled, modelProbe } from "@/testing/model-selection"
import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string } export type ModelKey = { providerID: string; modelID: string }
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return undefined
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies State
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local", name: "Local",
init: () => { init: () => {
const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const providers = useProviders() const providers = useProviders()
const models = useModels() const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
const id = createMemo(() => params.id || undefined) function isModelValid(model: ModelKey) {
const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const provider = providers.all().find((x) => x.id === model.providerID)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const [store, setStore] = createStore<{
current?: string
draft?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
const provider = providers.all().find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID) return !!provider?.models[model.modelID] && connected().has(model.providerID)
} }
const firstModel = (...items: Array<() => ModelKey | undefined>) => { function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
for (const item of items) { for (const modelFn of modelFns) {
const model = item() const model = modelFn()
if (!model) continue if (!model) continue
if (validModel(model)) return model if (isModelValid(model)) return model
} }
} }
const pickAgent = (name: string | undefined) => { let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
const items = list()
if (items.length === 0) return undefined
return items.find((item) => item.name === name) ?? items[0]
}
createEffect(() => { const agent = (() => {
const items = list() const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
if (items.length === 0) { const models = useModels()
if (store.current !== undefined) setStore("current", undefined)
return
}
if (items.some((item) => item.name === store.current)) return
setStore("current", items[0]?.name)
})
const scope = createMemo<State | undefined>(() => { const [store, setStore] = createStore<{
const session = id() current?: string
if (!session) return store.draft }>({
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session)) current: list()[0]?.name,
}) })
return {
createEffect(() => { list,
const session = id() current() {
if (!session) return const available = list()
if (available.length === 0) return undefined
const key = handoffKey(sdk.directory, session) return available.find((x) => x.name === store.current) ?? available[0]
const next = handoff.get(key) },
if (!next) return set(name: string | undefined) {
if (saved.session[session] !== undefined) { const available = list()
handoff.delete(key) if (available.length === 0) {
return setStore("current", undefined)
}
setSaved("session", session, clone(next))
handoff.delete(key)
})
const configuredModel = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const model = { providerID, modelID }
if (validModel(model)) return model
}
const recentModel = () => {
for (const item of models.recent.list()) {
if (validModel(item)) return item
}
}
const defaultModel = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const model = { providerID: provider.id, modelID: configured }
if (validModel(model)) return model
}
const first = Object.values(provider.models)[0]
if (!first) continue
const model = { providerID: provider.id, modelID: first.id }
if (validModel(model)) return model
}
}
const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
const agent = {
list,
current() {
return pickAgent(scope()?.agent ?? store.current)
},
set(name: string | undefined) {
const item = pickAgent(name)
if (!item) {
setStore("current", undefined)
return
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const prev = scope()
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return return
} }
setStore("draft", next) const match = name ? available.find((x) => x.name === name) : undefined
}) const value = match ?? available[0]
}, if (!value) return
move(direction: 1 | -1) { setStore("current", value.name)
const items = list() if (!value.model) return
if (items.length === 0) { setModel({
setStore("current", undefined) providerID: value.model.providerID,
return modelID: value.model.modelID,
} })
if (value.variant)
let next = items.findIndex((item) => item.name === agent.current()?.name) + direction models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
if (next < 0) next = items.length - 1 },
if (next >= items.length) next = 0 move(direction: 1 | -1) {
const item = items[next] const available = list()
if (!item) return if (available.length === 0) {
agent.set(item.name) setStore("current", undefined)
}, return
} }
let next = available.findIndex((x) => x.name === store.current) + direction
const current = () => { if (next < 0) next = available.length - 1
const item = firstModel( if (next >= available.length) next = 0
() => scope()?.model, const value = available[next]
() => agent.current()?.model, if (!value) return
fallback, setStore("current", value.name)
) if (!value.model) return
if (!item) return undefined setModel({
return models.find(item) providerID: value.model.providerID,
} modelID: value.model.modelID,
})
const configured = () => { if (value.variant)
const item = agent.current() models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
const model = current() },
if (!item || !model) return undefined
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
}
const selected = () => scope()?.variant
const snapshot = () => {
const model = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...next,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, state)
return
} }
setStore("draft", state) })()
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) const model = (() => {
const models = useModels()
const model = { const [ephemeral, setEphemeral] = createStore<{
ready: models.ready, model: Record<string, ModelKey | undefined>
current, }>({
recent, model: {},
list: models.list, })
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id) const resolveConfigured = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const key = { providerID, modelID }
if (isModelValid(key)) return key
}
const resolveRecent = () => {
for (const item of models.recent.list()) {
if (isModelValid(item)) return item
}
}
const resolveDefault = () => {
const defaults = providers.default()
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(provider.models)[0]
if (!first) continue
const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
}
const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel(
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)
if (!key) return undefined
return models.find(key)
})
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const currentModel = current()
if (!currentModel) return
const index = recentList.findIndex(
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
)
if (index === -1) return if (index === -1) return
let next = index + direction let next = index + direction
if (next < 0) next = items.length - 1 if (next < 0) next = recentList.length - 1
if (next >= items.length) next = 0 if (next >= recentList.length) next = 0
const entry = items[next] const val = recentList[next]
if (!entry) return if (!val) return
model.set({ providerID: entry.provider.id, modelID: entry.id })
}, model.set({
set(item: ModelKey | undefined, options?: { recent?: boolean }) { providerID: val.provider.id,
batch(() => { modelID: val.id,
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}) })
}, }
visible(item: ModelKey) {
return models.visible(item) const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
}, batch(() => {
setVisibility(item: ModelKey, visible: boolean) { const currentAgent = agent.current()
models.setVisibility(item, visible) const next = model ?? fallbackModel()
}, if (currentAgent) setEphemeral("model", currentAgent.name, next)
variant: { if (model) models.setVisibility(model, true)
configured, if (options?.recent && model) models.recent.push(model)
selected, })
current() { }
return resolveModelVariant({
variants: this.list(), setModel = set
selected: this.selected(),
configured: this.configured(), return {
}) ready: models.ready,
current,
recent,
list: models.list,
cycle,
set,
visible(model: ModelKey) {
return models.visible(model)
}, },
list() { setVisibility(model: ModelKey, visible: boolean) {
const item = current() models.setVisibility(model, visible)
if (!item?.variants) return []
return Object.keys(item.variants)
}, },
set(value: string | undefined) { variant: {
batch(() => { configured() {
const model = current() const a = agent.current()
setStore("last", { const m = current()
type: "variant", if (!a || !m) return undefined
agent: agent.current()?.name, return getConfiguredAgentVariant({
model: model ? { providerID: model.provider.id, modelID: model.id } : null, agent: { model: a.model, variant: a.variant },
variant: value ?? null, model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
}) })
write({ variant: value ?? null }) },
}) selected() {
}, const m = current()
cycle() { if (!m) return undefined
const items = this.list() return models.variant.get({ providerID: m.provider.id, modelID: m.id })
if (items.length === 0) return },
this.set( current() {
cycleModelVariant({ return resolveModelVariant({
variants: items, variants: this.list(),
selected: this.selected(), selected: this.selected(),
configured: this.configured(), configured: this.configured(),
}), })
) },
list() {
const m = current()
if (!m) return []
if (!m.variants) return []
return Object.keys(m.variants)
},
set(value: string | undefined) {
const m = current()
if (!m) return
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
}, },
}, }
} })()
const result = { const result = {
slug: createMemo(() => base64Encode(sdk.directory)), slug: createMemo(() => base64Encode(sdk.directory)),
model, model,
agent, agent,
session: {
reset() {
setStore("draft", undefined)
},
promote(dir: string, session: string) {
const next = clone(snapshot())
if (!next) return
if (dir === sdk.directory) {
setSaved("session", session, next)
setStore("draft", undefined)
return
}
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.variant ?? null,
})
},
},
} }
if (modelEnabled()) {
createEffect(() => {
const agent = result.agent.current()
const model = result.model.current()
modelProbe.set({
dir: sdk.directory,
sessionID: id(),
last: store.last,
agent: agent?.name,
model: model
? {
providerID: model.provider.id,
modelID: model.id,
name: model.name,
}
: undefined,
variant: result.model.variant.current() ?? null,
selected: result.model.variant.selected(),
configured: result.model.variant.configured(),
pick: scope(),
base: undefined,
current: store.current,
})
})
onCleanup(() => modelProbe.clear())
}
return result return result
}, },
}) })
@@ -44,16 +44,6 @@ describe("model variant", () => {
expect(value).toBe("high") expect(value).toBe("high")
}) })
test("lets an explicit default override the configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBeUndefined()
})
test("cycles from configured variant to next", () => { test("cycles from configured variant to next", () => {
const value = cycleModelVariant({ const value = cycleModelVariant({
variants: ["low", "high", "xhigh"], variants: ["low", "high", "xhigh"],
@@ -73,14 +63,4 @@ describe("model variant", () => {
expect(value).toBe("low") expect(value).toBe("low")
}) })
test("cycles from an explicit default to the first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})
expect(value).toBe("low")
})
}) })
+1 -3
View File
@@ -14,7 +14,7 @@ type Model = AgentModel & {
type VariantInput = { type VariantInput = {
variants: string[] variants: string[]
selected: string | null | undefined selected: string | undefined
configured: string | undefined configured: string | undefined
} }
@@ -29,7 +29,6 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
} }
export function resolveModelVariant(input: VariantInput) { export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined return undefined
@@ -37,7 +36,6 @@ export function resolveModelVariant(input: VariantInput) {
export function cycleModelVariant(input: VariantInput) { export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) { if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected) const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined if (index === input.variants.length - 1) return undefined
+2 -8
View File
@@ -151,11 +151,6 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession> type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
}
type PromptCacheEntry = { type PromptCacheEntry = {
value: PromptSession value: PromptSession
dispose: VoidFunction dispose: VoidFunction
@@ -270,7 +265,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return { return {
ready: () => session().ready(), ready: () => session().ready(),
@@ -286,8 +280,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
session().context.updateComment(path, commentID, next), session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
}, },
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(), reset: () => session().reset(),
} }
}, },
}) })
+3 -3
View File
@@ -80,11 +80,11 @@ export default function Layout(props: ParentProps) {
}) })
return ( return (
<Show when={state.resolved} keyed> <Show when={state.resolved}>
{(resolved) => ( {(resolved) => (
<SDKProvider directory={() => resolved}> <SDKProvider directory={resolved}>
<SyncProvider> <SyncProvider>
<DirectoryDataProvider directory={resolved}>{props.children}</DirectoryDataProvider> <DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
</SyncProvider> </SyncProvider>
</SDKProvider> </SDKProvider>
)} )}
+230 -264
View File
@@ -1,16 +1,4 @@
import { import { batch, createEffect, createMemo, For, on, onCleanup, onMount, ParentProps, Show, untrack } from "solid-js"
batch,
createEffect,
createMemo,
For,
on,
onCleanup,
onMount,
ParentProps,
Show,
untrack,
type Accessor,
} from "solid-js"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -140,6 +128,7 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory, autoselect: !initialDirectory,
routing: false,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverSession: undefined as string | undefined, hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
@@ -147,11 +136,12 @@ export default function Layout(props: ParentProps) {
nav: undefined as HTMLElement | undefined, nav: undefined as HTMLElement | undefined,
sortNow: Date.now(), sortNow: Date.now(),
sizing: false, sizing: false,
peek: undefined as string | undefined, peek: undefined as LocalProject | undefined,
peeked: false, peeked: false,
}) })
const editor = createInlineEditorController() const editor = createInlineEditorController()
let token = 0
const setBusy = (directory: string, value: boolean) => { const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory) const key = workspaceKey(directory)
if (value) { if (value) {
@@ -245,12 +235,6 @@ export default function Layout(props: ParentProps) {
return layout.projects.list().find((project) => project.worktree === id) return layout.projects.list().find((project) => project.worktree === id)
}) })
const peekProject = createMemo(() => {
const id = state.peek
if (!id) return
return layout.projects.list().find((project) => project.worktree === id)
})
createEffect(() => { createEffect(() => {
const p = hoverProjectData() const p = hoverProjectData()
if (p) { if (p) {
@@ -258,7 +242,7 @@ export default function Layout(props: ParentProps) {
clearTimeout(peekt) clearTimeout(peekt)
peekt = undefined peekt = undefined
} }
setState("peek", p.worktree) setState("peek", p)
setState("peeked", true) setState("peeked", true)
return return
} }
@@ -279,6 +263,7 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => { const autoselecting = createMemo(() => {
if (params.dir) return false if (params.dir) return false
if (state.routing) return true
if (!state.autoselect) return false if (!state.autoselect) return false
if (!pageReady()) return true if (!pageReady()) return true
if (!layoutReady()) return true if (!layoutReady()) return true
@@ -288,12 +273,16 @@ export default function Layout(props: ParentProps) {
}) })
createEffect(() => { createEffect(() => {
if (!state.autoselect) return if (!state.autoselect && !state.routing) return
const dir = params.dir const dir = params.dir
if (!dir) return if (!dir) return
const directory = decode64(dir) const directory = decode64(dir)
if (!directory) return if (!directory) return
setState("autoselect", false) token += 1
batch(() => {
setState("autoselect", false)
setState("routing", false)
})
}) })
const editorOpen = editor.editorOpen const editorOpen = editor.editorOpen
@@ -579,23 +568,32 @@ export default function Layout(props: ParentProps) {
if (!value.ready) return if (!value.ready) return
if (!value.layoutReady) return if (!value.layoutReady) return
if (!state.autoselect) return if (!state.autoselect) return
if (state.routing) return
if (value.dir) return if (value.dir) return
const last = server.projects.last() const last = server.projects.last()
const next =
if (value.list.length === 0) { value.list.length === 0
if (!last) return ? last
setState("autoselect", false) : (value.list.find((project) => project.worktree === last)?.worktree ?? value.list[0]?.worktree)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return if (!next) return
setState("autoselect", false)
openProject(next.worktree, false) const id = ++token
navigateToProject(next.worktree) batch(() => {
setState("autoselect", false)
setState("routing", true)
})
void navigateToProject(next, () => id === token && !params.dir).then(
(navigated) => {
if (id !== token) return
if (navigated) return
setState("routing", false)
},
() => {
if (id !== token) return
setState("routing", false)
},
)
}, },
), ),
) )
@@ -1229,14 +1227,19 @@ export default function Layout(props: ParentProps) {
return root return root
} }
async function navigateToProject(directory: string | undefined) { async function navigateToProject(directory: string | undefined, live = () => true) {
if (!directory) return if (!directory || !live()) return false
const root = projectRoot(directory) const root = projectRoot(directory)
server.projects.touch(root) const touch = () => {
if (!live()) return false
layout.projects.open(root)
server.projects.touch(root)
return true
}
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
let dirs = project const sandboxes =
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) project?.sandboxes ?? globalSync.data.project.find((item) => item.worktree === root)?.sandboxes ?? []
: [root] let dirs = effectiveWorkspaceOrder(root, [root, ...sandboxes], store.workspaceOrder[root])
const canOpen = (value: string | undefined) => { const canOpen = (value: string | undefined) => {
if (!value) return false if (!value) return false
return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
@@ -1247,13 +1250,16 @@ export default function Layout(props: ParentProps) {
.list({ directory: root }) .list({ directory: root })
.then((x) => x.data ?? []) .then((x) => x.data ?? [])
.catch(() => [] as string[]) .catch(() => [] as string[])
if (!live()) return false
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target) return canOpen(target)
} }
const openSession = async (target: { directory: string; id: string }) => { const openSession = async (target: { directory: string; id: string }) => {
if (!live()) return false
if (!canOpen(target.directory)) return false if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false }) const [data] = globalSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) { if (data.session.some((item) => item.id === target.id)) {
if (!touch()) return false
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() }) setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`) navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true return true
@@ -1262,8 +1268,10 @@ export default function Layout(props: ParentProps) {
.get({ sessionID: target.id }) .get({ sessionID: target.id })
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!live()) return false
if (!resolved?.directory) return false if (!resolved?.directory) return false
if (!canOpen(resolved.directory)) return false if (!canOpen(resolved.directory)) return false
if (!touch()) return false
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() }) setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`) navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
return true return true
@@ -1272,19 +1280,23 @@ export default function Layout(props: ParentProps) {
const projectSession = store.lastProjectSession[root] const projectSession = store.lastProjectSession[root]
if (projectSession?.id) { if (projectSession?.id) {
await refreshDirs(projectSession.directory) await refreshDirs(projectSession.directory)
if (!live()) return false
const opened = await openSession(projectSession) const opened = await openSession(projectSession)
if (opened) return if (opened) return true
if (!live()) return false
clearLastProjectSession(root) clearLastProjectSession(root)
} }
if (!live()) return false
const latest = latestRootSession( const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]), dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
Date.now(), Date.now(),
) )
if (latest && (await openSession(latest))) { if (latest && (await openSession(latest))) {
return return true
} }
if (!live()) return false
const fetched = latestRootSession( const fetched = latestRootSession(
await Promise.all( await Promise.all(
dirs.map(async (item) => ({ dirs.map(async (item) => ({
@@ -1297,11 +1309,14 @@ export default function Layout(props: ParentProps) {
), ),
Date.now(), Date.now(),
) )
if (!live()) return false
if (fetched && (await openSession(fetched))) { if (fetched && (await openSession(fetched))) {
return return true
} }
if (!touch()) return false
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
return true
} }
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
@@ -1950,32 +1965,17 @@ export default function Layout(props: ParentProps) {
setHoverSession, setHoverSession,
} }
const SidebarPanel = (panelProps: { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
project: Accessor<LocalProject | undefined>
mobile?: boolean
merged?: boolean
}) => {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened()) const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const projectName = createMemo(() => { const projectName = createMemo(() => {
const item = project() const project = panelProps.project
if (!item) return "" if (!project) return ""
return item.name || getFilename(item.worktree) return project.name || getFilename(project.worktree)
})
const projectId = createMemo(() => project()?.id ?? "")
const worktree = createMemo(() => project()?.worktree ?? "")
const slug = createMemo(() => {
const dir = worktree()
if (!dir) return ""
return base64Encode(dir)
})
const workspaces = createMemo(() => {
const item = project()
if (!item) return [] as string[]
return workspaceIds(item)
}) })
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() => const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
) )
@@ -1984,15 +1984,10 @@ export default function Layout(props: ParentProps) {
.filter((directory) => notification.project.unseenCount(directory) > 0) .filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory)) .forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => { const workspacesEnabled = createMemo(() => {
const item = project() const project = panelProps.project
if (!item) return false if (!project) return false
if (item.vcs !== "git") return false if (project.vcs !== "git") return false
return layout.sidebar.workspaces(item.worktree)() return layout.sidebar.workspaces(project.worktree)()
})
const canToggle = createMemo(() => {
const item = project()
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
}) })
const homedir = createMemo(() => globalSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
@@ -2011,197 +2006,168 @@ export default function Layout(props: ParentProps) {
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
}} }}
> >
<Show when={project()}> <Show when={panelProps.project}>
<> {(p) => (
<div class="shrink-0 pl-1 py-1"> <>
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0"> <div class="shrink-0 pl-1 py-1">
<div class="flex flex-col min-w-0"> <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<InlineEditor <div class="flex flex-col min-w-0">
id={`project:${projectId()}`} <InlineEditor
value={projectName} id={`project:${projectId()}`}
onSave={(next) => { value={projectName}
const item = project() onSave={(next) => renameProject(p(), next)}
if (!item) return class="text-14-medium text-text-strong truncate"
renameProject(item, next) displayClass="text-14-medium text-text-strong truncate"
}} stopPropagation
class="text-14-medium text-text-strong truncate" />
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip <Tooltip
placement="bottom" placement="bottom"
gutter={2} gutter={2}
value={worktree()} value={p().worktree}
class="shrink-0" class="shrink-0"
contentStyle={{ contentStyle={{
"max-width": "640px", "max-width": "640px",
transform: "translate3d(52px, 0, 0)", transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
createWorkspace(item)
}} }}
> >
{language.t("workspace.new")} <span class="text-12-regular text-text-base truncate select-text">
</Button> {p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div> </div>
<div class="relative flex-1 min-h-0">
<DragDropProvider <DropdownMenu modal={!sidebarHovering()}>
onDragStart={handleWorkspaceDragStart} <DropdownMenu.Trigger
onDragEnd={handleWorkspaceDragEnd} as={IconButton}
onDragOver={handleWorkspaceDragOver} icon="dot-grid"
collisionDetector={closestCenter} variant="ghost"
> data-action="project-menu"
<DragDropSensors /> data-project={base64Encode(p().worktree)}
<ConstrainDragXAxis /> class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
<div classList={{
ref={(el) => { "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
if (!panelProps.mobile) scrollContainerRef = el }}
}} aria-label={language.t("common.moreOptions")}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" />
> <DropdownMenu.Portal>
<SortableProvider ids={workspaces()}> <DropdownMenu.Content class="mt-1">
<For each={workspaces()}> <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
{(directory) => ( <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
<SortableWorkspace </DropdownMenu.Item>
ctx={workspaceSidebarCtx} <DropdownMenu.Item
directory={directory} data-action="project-workspaces-toggle"
project={project()!} data-project={base64Encode(p().worktree)}
sortNow={sortNow} disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
mobile={panelProps.mobile} onSelect={() => toggleProjectWorkspaces(p())}
popover={popover()} >
/> <DropdownMenu.ItemLabel>
)} {layout.sidebar.workspaces(p().worktree)()
</For> ? language.t("sidebar.workspaces.disable")
</SortableProvider> : language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</div> </div>
<DragOverlay> <div class="flex-1 min-h-0">
<WorkspaceDragOverlay <LocalWorkspace
sidebarProject={sidebarProject} ctx={workspaceSidebarCtx}
activeWorkspace={() => store.activeWorkspace} project={p()}
workspaceLabel={workspaceLabel} sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/> />
</DragOverlay> </div>
</DragDropProvider> </>
</div> }
</> >
</Show> <>
</div> <div class="shrink-0 py-4">
</> <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={p()}
sortNow={sortNow}
mobile={panelProps.mobile}
popover={popover()}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
)}
</Show> </Show>
<div <div
@@ -2261,10 +2227,10 @@ export default function Layout(props: ParentProps) {
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
renderPanel={() => renderPanel={() =>
mobile ? ( mobile ? (
<SidebarPanel project={currentProject} mobile /> <SidebarPanel project={currentProject()} mobile />
) : ( ) : (
<Show when={currentProject()}> <Show when={currentProject()}>
<SidebarPanel project={currentProject} merged /> <SidebarPanel project={currentProject()} merged />
</Show> </Show>
) )
} }
@@ -2392,8 +2358,8 @@ export default function Layout(props: ParentProps) {
arm() arm()
}} }}
> >
<Show when={peekProject()}> <Show when={state.peek}>
<SidebarPanel project={peekProject} merged={false} /> <SidebarPanel project={state.peek} merged={false} />
</Show> </Show>
</div> </div>
+105 -44
View File
@@ -9,13 +9,14 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent" import { agentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
@@ -101,46 +102,94 @@ const SessionRow = (props: {
warmPress: () => void warmPress: () => void
warmFocus: () => void warmFocus: () => void
cancelHoverPrefetch: () => void cancelHoverPrefetch: () => void
}): JSX.Element => ( }): JSX.Element => {
<A const [slot, setSlot] = createStore({
href={`/${props.slug}/session/${props.session.id}`} open: false,
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} show: false,
onPointerDown={props.warmPress} fade: false,
onPointerEnter={props.warmHover} })
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus} let f: number | undefined
onClick={() => { const clear = () => {
props.setHoverSession(undefined) if (f !== undefined) window.clearTimeout(f)
if (props.sidebarOpened()) return f = undefined
props.clearHoverProjectSoon() }
}}
> onCleanup(clear)
<div class="flex items-center gap-1 w-full"> createEffect(
<div on(
class="shrink-0 size-6 flex items-center justify-center" () => props.isWorking(),
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} (on, prev) => {
> clear()
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> if (on) {
<Match when={props.isWorking()}> setSlot({ open: true, show: true, fade: false })
<Spinner class="size-[15px]" /> return
</Match> }
<Match when={props.hasPermissions()}> if (prev) {
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> setSlot({ open: false, show: true, fade: true })
</Match> f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
<Match when={props.hasError()}> return
<div class="size-1.5 rounded-full bg-text-diff-delete-base" /> }
</Match> setSlot({ open: false, show: false, fade: false })
<Match when={props.unseenCount() > 0}> },
<div class="size-1.5 rounded-full bg-text-interactive-base" /> { defer: true },
</Match> ),
</Switch> )
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
class={`relative flex items-center min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
<Show when={!props.isWorking() && (props.hasPermissions() || props.hasError() || props.unseenCount() > 0)}>
<div
classList={{
"absolute left-0 top-1/2 -translate-y-1/2 size-1.5 rounded-full": true,
"bg-surface-warning-strong": props.hasPermissions(),
"bg-text-diff-delete-base": !props.hasPermissions() && props.hasError(),
"bg-text-interactive-base": !props.hasPermissions() && !props.hasError() && props.unseenCount() > 0,
}}
aria-hidden="true"
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: slot.open ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={slot.show}>
<div
class="transition-opacity duration-200 ease-out"
classList={{
"opacity-0": slot.fade,
}}
>
<Spinner class="size-4" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div> </div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> </A>
{props.session.title} )
</span> }
</div>
</A>
)
const SessionHoverPreview = (props: { const SessionHoverPreview = (props: {
mobile?: boolean mobile?: boolean
@@ -219,7 +268,19 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const tint = createMemo(() => { const tint = createMemo(() => {
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent) const messages = sessionStore.message[props.session.id]
if (!messages) return undefined
let user: Message | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== "user") continue
user = message
break
}
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agentColor(user.agent, agent?.color)
}) })
const hoverMessages = createMemo(() => const hoverMessages = createMemo(() =>
@@ -298,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors class="group/session relative w-full rounded-md cursor-default pl-3 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Show <Show
@@ -384,7 +445,7 @@ export const NewSessionItem = (props: {
> >
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
<div class="shrink-0 size-6 flex items-center justify-center"> <div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="new-session" size="small" class="text-icon-weak" /> <Icon name="plus-small" size="small" class="text-icon-weak" />
</div> </div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label} {label}
@@ -217,7 +217,7 @@ const WorkspaceActions = (props: {
<Show when={!props.touch()}> <Show when={!props.touch()}>
<Tooltip value={props.language.t("command.session.new")} placement="top"> <Tooltip value={props.language.t("command.session.new")} placement="top">
<IconButton <IconButton
icon="new-session" icon="plus-small"
variant="ghost" variant="ghost"
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
data-action="workspace-new-session" data-action="workspace-new-session"
+6 -5
View File
@@ -44,7 +44,7 @@ import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalByI
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
@@ -490,7 +490,7 @@ export default function Page() {
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
if (prev.id && !next.id) local.session.reset() if (!next.id) resetSessionModel(local)
}, },
{ defer: true }, { defer: true },
), ),
@@ -1477,7 +1477,6 @@ export default function Page() {
const fork = (input: { sessionID: string; messageID: string }) => { const fork = (input: { sessionID: string; messageID: string }) => {
const value = draft(input.messageID) const value = draft(input.messageID)
const dir = base64Encode(sdk.directory)
return sdk.client.session return sdk.client.session
.fork(input) .fork(input)
.then((result) => { .then((result) => {
@@ -1489,8 +1488,10 @@ export default function Page() {
}) })
return return
} }
prompt.set(value, undefined, { dir, id: next.id }) navigate(`/${base64Encode(sdk.directory)}/session/${next.id}`)
navigate(`/${dir}/session/${next.id}`) requestAnimationFrame(() => {
prompt.set(value)
})
}) })
.catch(fail) .catch(fail)
} }
@@ -27,7 +27,6 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
type MessageComment = { type MessageComment = {
@@ -247,7 +246,6 @@ export function MessageTimeline(props: {
return sync.data.session_status[id] ?? idle return sync.data.session_status[id] ?? idle
}) })
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({ const [slot, setSlot] = createStore({
open: false, open: false,
@@ -691,7 +689,7 @@ export function MessageTimeline(props: {
"opacity-0": slot.fade, "opacity-0": slot.fade,
}} }}
> >
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} /> <Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
</div> </div>
</Show> </Show>
</div> </div>
@@ -14,38 +14,145 @@ const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant"
}) as UserMessage }) as UserMessage
describe("syncSessionModel", () => { describe("syncSessionModel", () => {
test("restores the last message through session state", () => { test("restores the last message model and variant", () => {
const calls: unknown[] = [] const calls: unknown[] = []
syncSessionModel( syncSessionModel(
{ {
session: { agent: {
restore(value) { current() {
calls.push(value) return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
}, },
reset() {},
}, },
}, },
message({ variant: "high" }), message({ variant: "high" }),
) )
expect(calls).toEqual([message({ variant: "high" })]) expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("skips variant when the model falls back", () => {
const calls: unknown[] = []
syncSessionModel(
{
agent: {
current() {
return undefined
},
set(value) {
calls.push(["agent", value])
},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return { id: "gpt-5", provider: { id: "openai" } }
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
},
message({ variant: "high" }),
)
expect(calls).toEqual([
["agent", "build"],
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
])
}) })
}) })
describe("resetSessionModel", () => { describe("resetSessionModel", () => {
test("clears draft session state", () => { test("restores the current agent defaults", () => {
const calls: string[] = [] const calls: unknown[] = []
resetSessionModel({ resetSessionModel({
session: { agent: {
reset() { current() {
calls.push("reset") return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: "high",
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
}, },
restore() {},
}, },
}) })
expect(calls).toEqual(["reset"]) expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", "high"],
])
})
test("clears the variant when the agent has none", () => {
const calls: unknown[] = []
resetSessionModel({
agent: {
current() {
return {
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
}
},
set() {},
},
model: {
set(value) {
calls.push(["model", value])
},
current() {
return undefined
},
variant: {
set(value) {
calls.push(["variant", value])
},
},
},
})
expect(calls).toEqual([
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
["variant", undefined],
])
}) })
}) })
@@ -1,16 +1,48 @@
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { batch } from "solid-js"
type Local = { type Local = {
session: { agent: {
reset(): void current():
restore(msg: UserMessage): void | {
model?: UserMessage["model"]
variant?: string
}
| undefined
set(name: string | undefined): void
}
model: {
set(model: UserMessage["model"] | undefined): void
current():
| {
id: string
provider: { id: string }
}
| undefined
variant: {
set(value: string | undefined): void
}
} }
} }
export const resetSessionModel = (local: Local) => { export const resetSessionModel = (local: Local) => {
local.session.reset() const agent = local.agent.current()
if (!agent) return
batch(() => {
local.model.set(agent.model)
local.model.variant.set(agent.variant)
})
} }
export const syncSessionModel = (local: Local, msg: UserMessage) => { export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg) batch(() => {
local.agent.set(msg.agent)
local.model.set(msg.model)
})
const model = local.model.current()
if (!model) return
if (model.provider.id !== msg.model.providerID) return
if (model.id !== msg.model.modelID) return
local.model.variant.set(msg.variant)
} }
@@ -351,7 +351,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
description: language.t("command.model.choose.description"), description: language.t("command.model.choose.description"),
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />), onSelect: () => dialog.show(() => <DialogSelectModel />),
}), }),
mcpCommand({ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
@@ -1,80 +0,0 @@
type ModelKey = {
providerID: string
modelID: string
}
type State = {
agent?: string
model?: ModelKey | null
variant?: string | null
}
export type ModelProbeState = {
dir?: string
sessionID?: string
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
agent?: string
model?: (ModelKey & { name?: string }) | undefined
variant?: string | null
selected?: string | null
configured?: string
pick?: State
base?: State
current?: string
}
export type ModelWindow = Window & {
__opencode_e2e?: {
model?: {
enabled?: boolean
current?: ModelProbeState
}
}
}
const clone = (state?: State) => {
if (!state) return undefined
return {
...state,
model: state.model ? { ...state.model } : state.model,
}
}
export const modelEnabled = () => {
if (typeof window === "undefined") return false
return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
}
const root = () => {
if (!modelEnabled()) return
return (window as ModelWindow).__opencode_e2e?.model
}
export const modelProbe = {
set(input: ModelProbeState) {
const state = root()
if (!state) return
state.current = {
...input,
model: input.model ? { ...input.model } : undefined,
last: input.last
? {
...input.last,
model: input.last.model ? { ...input.last.model } : input.last.model,
}
: undefined,
pick: clone(input.pick),
base: clone(input.base),
}
},
clear() {
const state = root()
if (!state) return
state.current = undefined
},
}
-6
View File
@@ -1,5 +1,3 @@
import type { ModelProbeState } from "./model-selection"
export const terminalAttr = "data-pty-id" export const terminalAttr = "data-pty-id"
export type TerminalProbeState = { export type TerminalProbeState = {
@@ -15,10 +13,6 @@ type TerminalProbeControl = {
export type E2EWindow = Window & { export type E2EWindow = Window & {
__opencode_e2e?: { __opencode_e2e?: {
model?: {
enabled?: boolean
current?: ModelProbeState
}
terminal?: { terminal?: {
enabled?: boolean enabled?: boolean
terminals?: Record<string, TerminalProbeState> terminals?: Record<string, TerminalProbeState>
-12
View File
@@ -9,15 +9,3 @@ export function agentColor(name: string, custom?: string) {
if (custom) return custom if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()] return defaults[name] ?? defaults[name.toLowerCase()]
} }
export function messageAgentColor(
list: readonly { role: string; agent?: string }[] | undefined,
agents: readonly { name: string; color?: string }[],
) {
if (!list) return undefined
for (let i = list.length - 1; i >= 0; i--) {
const item = list[i]
if (item.role !== "user" || !item.agent) continue
return agentColor(item.agent, agents.find((agent) => agent.name === item.agent)?.color)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.2.25",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.27", "version": "1.2.25",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.27", "version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.27", "version": "1.2.25",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.2.27", "version": "1.2.25",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.2.27", "version": "1.2.25",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.27", "version": "1.2.25",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.2.27" version = "1.2.25"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.27", "version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
-35
View File
@@ -34,7 +34,6 @@ Instructions to follow when writing Effect.
- Use `Effect.gen(function* () { ... })` for composition. - Use `Effect.gen(function* () { ... })` for composition.
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. - 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.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 ## Time
@@ -43,37 +42,3 @@ Instructions to follow when writing Effect.
## Errors ## Errors
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - 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.
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.2.27", "version": "1.2.25",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -95,7 +95,6 @@
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87", "@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87", "@opentui/solid": "0.1.87",
"@effect/platform-node": "4.0.0-beta.31",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
+4 -7
View File
@@ -11,11 +11,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg)) 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 loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService const service = yield* AccountService
@@ -104,10 +99,11 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active() const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) => const opts = groups.flatMap((group) =>
group.orgs.map((org) => { group.orgs.map((org) => {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return { return {
value: { orgID: org.id, accountID: group.account.id, label: org.name }, value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive label: isActive
@@ -136,10 +132,11 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found") if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active() const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) { for (const group of groups) {
for (const org of group.orgs) { for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " 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 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 const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
+1 -1
View File
@@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") { if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset }) throw new PermissionNext.DeniedError(ruleset)
} }
} }
}, },
@@ -1,13 +0,0 @@
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",
) {}
@@ -1,12 +0,0 @@
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
@@ -1,64 +0,0 @@
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))
}
}
+2 -11
View File
@@ -1,14 +1,5 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service" import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service" import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make( export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
}
File diff suppressed because it is too large Load Diff
+60 -104
View File
@@ -1,115 +1,71 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag" import { Flag } from "../flag/flag"
import { Filesystem } from "../util/filesystem" 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 { export namespace FileTime {
export function read(sessionID: SessionID, file: string) { const log = Log.create({ service: "file.time" })
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file))) // 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 get(sessionID: SessionID, file: string) { export function get(sessionID: string, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file))) return state().read[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> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn))) 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.`,
)
}
} }
} }
+101 -123
View File
@@ -1,8 +1,7 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod" import z from "zod"
import { Instance } from "../project/instance"
import { Log } from "../util/log" import { Log } from "../util/log"
import { FileIgnore } from "./ignore" import { FileIgnore } from "./ignore"
import { Config } from "../config/config" import { Config } from "../config/config"
@@ -10,139 +9,118 @@ import path from "path"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect"
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
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
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export namespace FileWatcher { export namespace FileWatcher {
export const Event = event const log = Log.create({ service: "file.watcher" })
/** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher()
}
const init = Effect.fn("FileWatcherService.init")(function* () {}) export const Event = {
Updated: BusEvent.define(
export namespace FileWatcherService { "file.watcher.updated",
export interface Service { z.object({
readonly init: () => Effect.Effect<void> file: z.string(),
} event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}
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 }))
}), }),
), ),
}
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 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 })
const w = watcher()
if (!w) return {}
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()
}
} }
+3 -11
View File
@@ -1,5 +1,3 @@
import { Config } from "effect"
function truthy(key: string) { function truthy(key: string) {
const value = process.env[key]?.toLowerCase() const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1" return value === "true" || value === "1"
@@ -42,12 +40,8 @@ export namespace Flag {
// Experimental // Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
export const OPENCODE_EXPERIMENTAL_FILEWATCHER = Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( export const OPENCODE_EXPERIMENTAL_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_FILEWATCHER")
Config.withDefault(false), export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
)
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean(
"OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER",
).pipe(Config.withDefault(false))
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
@@ -61,9 +55,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") 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_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL") export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe( export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
Config.withDefault(false),
)
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE") 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_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN") export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
+107 -128
View File
@@ -9,13 +9,10 @@ import { Config } from "../config/config"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Process } from "../util/process" 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 { export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z export const Status = z
.object({ .object({
name: z.string(), name: z.string(),
@@ -27,135 +24,117 @@ export namespace Format {
}) })
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
export async function init() { const state = Instance.state(async () => {
return runPromiseInstance(FormatService.use((s) => s.init())) 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 status() { export async function status() {
return runPromiseInstance(FormatService.use((s) => s.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 namespace FormatService { export function init() {
export interface Service { log.info("init")
readonly init: () => Effect.Effect<void> Bus.subscribe(File.Event.Edited, async (payload) => {
readonly status: () => Effect.Effect<Format.Status[]> const file = payload.properties.file
} log.info("formatting", { file })
} const ext = path.extname(file)
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") { for (const item of await getFormatter(ext)) {
static readonly layer = Layer.effect( log.info("running", { command: item.command })
FormatService, try {
Effect.gen(function* () { const proc = Process.spawn(
const instance = yield* InstanceContext item.command.map((x) => x.replace("$FILE", file)),
{
const enabled: Record<string, boolean> = {} cwd: Instance.directory,
const formatters: Record<string, Formatter.Info> = {} env: { ...process.env, ...item.environment },
stdout: "ignore",
const cfg = yield* Effect.promise(() => Config.get()) stderr: "ignore",
},
if (cfg.formatter !== false) { )
for (const item of Object.values(Formatter)) { const exit = await proc.exited
formatters[item.name] = item if (exit !== 0)
} log.error("failed", {
for (const [name, item] of Object.entries(cfg.formatter ?? {})) { command: item.command,
if (item.disabled) { ...item.environment,
delete formatters[name] })
continue } catch (error) {
} log.error("failed to format file", {
const result = mergeDeep(formatters[name] ?? {}, { error,
command: [], command: item.command,
extensions: [], ...item.environment,
...item, file,
}) 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 })
}),
)
} }
+5
View File
@@ -47,6 +47,11 @@ process.on("uncaughtException", (e) => {
}) })
}) })
// Ensure the process exits on terminal hangup (eg. closing the terminal tab).
// Without this, long-running commands like `serve` block on a never-resolving
// promise and survive as orphaned processes.
process.on("SIGHUP", () => process.exit())
let cli = yargs(hideBin(process.argv)) let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true }) .parserConfiguration({ "populate--": true })
.scriptName("opencode") .scriptName("opencode")
+210
View File
@@ -0,0 +1,210 @@
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) => a.id.localeCompare(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.`,
)
}
}
}
+229 -28
View File
@@ -1,11 +1,21 @@
import { runPromiseInstance } from "@/effect/runtime" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionID, MessageID } from "@/session/schema"
import { PermissionID } from "./schema"
import { Instance } from "@/project/instance"
import { Database, eq } from "@/storage/db"
import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { ProjectID } from "@/project/schema"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import os from "os" import os from "os"
import * as S from "./service" import z from "zod"
export namespace PermissionNext { export namespace PermissionNext {
const log = Log.create({ service: "permission" })
function expand(pattern: string): string { function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir() if (pattern === "~") return os.homedir()
@@ -14,22 +24,26 @@ export namespace PermissionNext {
return pattern return pattern
} }
export const Action = S.Action export const Action = z.enum(["allow", "deny", "ask"]).meta({
export type Action = S.Action ref: "PermissionAction",
export const Rule = S.Rule })
export type Rule = S.Rule export type Action = z.infer<typeof Action>
export const Ruleset = S.Ruleset
export type Ruleset = S.Ruleset export const Rule = z
export const Request = S.Request .object({
export type Request = S.Request permission: z.string(),
export const Reply = S.Reply pattern: z.string(),
export type Reply = S.Reply action: Action,
export const Approval = S.Approval })
export const Event = S.Event .meta({
export const Service = S.PermissionService ref: "PermissionRule",
export const RejectedError = S.RejectedError })
export const CorrectedError = S.CorrectedError export type Rule = z.infer<typeof Rule>
export const DeniedError = S.DeniedError
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export function fromConfig(permission: Config.Permission) { export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = [] const ruleset: Ruleset = []
@@ -53,20 +67,178 @@ export namespace PermissionNext {
return rulesets.flat() return rulesets.flat()
} }
export const ask = fn(S.AskInput, async (input) => export const Request = z
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))), .object({
) id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export const reply = fn(S.ReplyInput, async (input) => export type Request = z.infer<typeof Request>
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
)
export async function list() { export const Reply = z.enum(["once", "always", "reject"])
return runPromiseInstance(S.PermissionService.use((service) => service.list())) export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
} }
interface PendingEntry {
info: Request
resolve: () => void
reject: (e: any) => void
}
const state = Instance.state(() => {
const projectID = Instance.project.id
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
)
const stored = row?.data ?? ([] as Ruleset)
return {
pending: new Map<PermissionID, PendingEntry>(),
approved: stored,
}
})
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? PermissionID.ascending()
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending.set(id, {
info,
resolve,
reject,
})
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
},
)
export const reply = fn(
z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
}),
async (input) => {
const s = await state()
const existing = s.pending.get(input.requestID)
if (!existing) return
s.pending.delete(input.requestID)
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
// Reject all other pending permissions for this session
const sessionID = existing.info.sessionID
for (const [id, pending] of s.pending) {
if (pending.info.sessionID === sessionID) {
s.pending.delete(id)
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "reject",
})
pending.reject(new RejectedError())
}
}
return
}
if (input.reply === "once") {
existing.resolve()
return
}
if (input.reply === "always") {
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
existing.resolve()
const sessionID = existing.info.sessionID
for (const [id, pending] of s.pending) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
s.pending.delete(id)
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "always",
})
pending.resolve()
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return
}
},
)
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return S.evaluate(permission, pattern, ...rulesets) const merged = merge(...rulesets)
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
} }
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
@@ -75,10 +247,39 @@ export namespace PermissionNext {
const result = new Set<string>() const result = new Set<string>()
for (const tool of tools) { for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
if (!rule) continue if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool) if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
} }
return result return result
} }
/** User rejected without message - halts execution */
export class RejectedError extends Error {
constructor() {
super(`The user rejected permission to use this specific tool call.`)
}
}
/** User rejected with message - continues with guidance */
export class CorrectedError extends Error {
constructor(message: string) {
super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`)
}
}
/** Auto-rejected by config rule - halts execution */
export class DeniedError extends Error {
constructor(public readonly ruleset: Ruleset) {
super(
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
)
}
}
export async function list() {
const s = await state()
return Array.from(s.pending.values(), (x) => x.info)
}
} }
+10 -10
View File
@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema" import { withStatics } from "@/util/schema"
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) { const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
static make(id: string): PermissionID {
return this.makeUnsafe(id)
}
static ascending(id?: string): PermissionID { export type PermissionID = typeof permissionIdSchema.Type
return this.makeUnsafe(Identifier.ascending("permission", id))
}
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID> export const PermissionID = permissionIdSchema.pipe(
} withStatics((schema: typeof permissionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
})),
)
-251
View File
@@ -1,251 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
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 { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod"
import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export type PermissionError = DeniedError | RejectedError | CorrectedError
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset,
})
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
})
export declare namespace PermissionService {
export interface Api {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
"@opencode/PermissionNext",
) {
static readonly layer = Layer.effect(
PermissionService,
Effect.gen(function* () {
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 { ruleset, ...request } = input
let needsAsk = false
for (const pattern of request.patterns) {
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({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
if (rule.action === "allow") continue
needsAsk = true
}
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
...request,
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
void Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID)
if (!existing) return
pending.delete(input.requestID)
void Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply === "once") return
for (const pattern of existing.info.always) {
approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
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, approved).action === "allow",
)
if (!ok) continue
pending.delete(id)
void Bus.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
})
const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return PermissionService.of({ ask, reply, list })
}),
)
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
-18
View File
@@ -309,24 +309,6 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
} }
const parts = await sdk.session
.message({
path: {
id: incoming.message.sessionID,
messageID: incoming.message.id,
},
query: {
directory: input.directory,
},
throwOnError: true,
})
.catch(() => undefined)
if (parts?.data.parts?.some((part) => part.type === "compaction")) {
output.headers["x-initiator"] = "agent"
return
}
const session = await sdk.session const session = await sdk.session
.get({ .get({
path: { path: {
+5 -6
View File
@@ -1,28 +1,27 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format" import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher" import { FileWatcher } from "../file/watcher"
import { File } from "../file" import { File } from "../file"
import { Project } from "./project" import { Project } from "./project"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { VcsService } from "./vcs" import { Vcs } from "./vcs"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation" import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
await Format.init() Format.init()
await LSP.init() await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init())) FileWatcher.init()
File.init() File.init()
await runPromiseInstance(VcsService.use((s) => s.init())) Vcs.init()
Snapshot.init() Snapshot.init()
Truncate.init() Truncate.init()
+7 -16
View File
@@ -1,3 +1,4 @@
import { Effect } from "effect"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Context } from "../util/context" import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
@@ -5,7 +6,7 @@ import { State } from "./state"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { disposeInstance } from "@/effect/instance-registry" import { InstanceState } from "@/util/instance-state"
interface Context { interface Context {
directory: string directory: string
@@ -101,33 +102,23 @@ export const Instance = {
if (Instance.worktree === "/") return false if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath) 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 { state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose) return State.create(() => Instance.directory, init, dispose)
}, },
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory) const directory = Filesystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory }) Log.Default.info("reloading instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
cache.delete(directory) cache.delete(directory)
const next = track(directory, boot({ ...input, directory })) const next = track(directory, boot({ ...input, directory }))
emit(directory) emit(directory)
return await next return await next
}, },
async dispose() { async dispose() {
const directory = Instance.directory Log.Default.info("disposing instance", { directory: Instance.directory })
Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(Instance.directory)
cache.delete(directory) emit(Instance.directory)
emit(directory)
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all if (disposal.all) return disposal.all
+2 -3
View File
@@ -147,7 +147,7 @@ export namespace Project {
// generate id from root commit // generate id from root commit
if (!id) { if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { const roots = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: sandbox, cwd: sandbox,
}) })
.then(async (result) => .then(async (result) =>
@@ -170,8 +170,7 @@ export namespace Project {
id = roots[0] ? ProjectID.make(roots[0]) : undefined id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) { if (id) {
// Write to common dir so the cache is shared across worktrees. await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
} }
} }
+45 -53
View File
@@ -1,12 +1,11 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import path from "path"
import z from "zod" import z from "zod"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Instance } from "./instance" import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect"
const log = Log.create({ service: "vcs" }) const log = Log.create({ service: "vcs" })
@@ -28,57 +27,50 @@ export namespace Vcs {
ref: "VcsInfo", ref: "VcsInfo",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
}
export namespace VcsService { async function currentBranch() {
export interface Service { const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
readonly init: () => Effect.Effect<void> cwd: Instance.worktree,
readonly branch: () => Effect.Effect<string | undefined> })
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 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
}),
})
}),
)
}
+41 -15
View File
@@ -1,9 +1,12 @@
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 type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service" import * as Auth from "@/auth/service"
import { InstanceState } from "@/util/instance-state"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod" import z from "zod"
export const Method = z export const Method = z
@@ -51,13 +54,21 @@ export type ProviderAuthError =
export namespace ProviderAuthService { export namespace ProviderAuthService {
export interface Service { export interface Service {
/** Get available auth methods for each provider (e.g. OAuth, API key). */
readonly methods: () => Effect.Effect<Record<string, Method[]>> 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> 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: { readonly callback: (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
code?: string code?: string
}) => Effect.Effect<void, ProviderAuthError> }) => 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>
} }
} }
@@ -68,29 +79,33 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
ProviderAuthService, ProviderAuthService,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.AuthService const auth = yield* Auth.AuthService
const hooks = yield* Effect.promise(async () => { const state = yield* InstanceState.make({
const mod = await import("../plugin") lookup: () =>
return pipe( Effect.promise(async () => {
await mod.Plugin.list(), const methods = pipe(
filter((x) => x.auth?.provider !== undefined), await Plugin.list(),
map((x) => [x.auth!.provider, x.auth!] as const), filter((x) => x.auth?.provider !== undefined),
fromEntries(), map((x) => [x.auth!.provider, x.auth!] as const),
) fromEntries(),
)
return { methods, pending: new Map<ProviderID, AuthOuathResult>() }
}),
}) })
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuthService.methods")(function* () { const methods = Effect.fn("ProviderAuthService.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) const x = yield* InstanceState.get(state)
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
}) })
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
}) { }) {
const method = hooks[input.providerID].methods[input.method] const s = yield* InstanceState.get(state)
const method = s.methods[input.providerID].methods[input.method]
if (method.type !== "oauth") return if (method.type !== "oauth") return
const result = yield* Effect.promise(() => method.authorize()) const result = yield* Effect.promise(() => method.authorize())
pending.set(input.providerID, result) s.pending.set(input.providerID, result)
return { return {
url: result.url, url: result.url,
method: result.method, method: result.method,
@@ -103,14 +118,17 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
method: number method: number
code?: string code?: string
}) { }) {
const match = pending.get(input.providerID) const s = yield* InstanceState.get(state)
const match = s.pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) if (match.method === "code" && !input.code)
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(), match.method === "code" ? match.callback(input.code!) : match.callback(),
) )
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) { if ("key" in result) {
@@ -131,10 +149,18 @@ 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({ return ProviderAuthService.of({
methods, methods,
authorize, authorize,
callback, callback,
api,
}) })
}), }),
) )
+21 -5
View File
@@ -1,16 +1,25 @@
import { Effect, ManagedRuntime } from "effect"
import z from "zod" import z from "zod"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import * as S from "./auth-service" import * as S from "./auth-service"
import { ProviderID } from "./schema" 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 namespace ProviderAuth {
export const Method = S.Method export const Method = S.Method
export type Method = S.Method export type Method = S.Method
export async function methods() { export async function methods() {
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) return runPromise((service) => service.methods())
} }
export const Authorization = S.Authorization export const Authorization = S.Authorization
@@ -21,8 +30,7 @@ export namespace ProviderAuth {
providerID: ProviderID.zod, providerID: ProviderID.zod,
method: z.number(), method: z.number(),
}), }),
async (input): Promise<Authorization | undefined> => async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
) )
export const callback = fn( export const callback = fn(
@@ -31,7 +39,15 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
code: z.string().optional(), code: z.string().optional(),
}), }),
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), 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)),
) )
export import OauthMissing = S.OauthMissing export import OauthMissing = S.OauthMissing
+1 -2
View File
@@ -167,8 +167,7 @@ export namespace ProviderError {
export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
const m = message(input.providerID, input.error) const m = message(input.providerID, input.error)
const body = json(input.error.responseBody) if (isOverflow(m) || input.error.statusCode === 413) {
if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
return { return {
type: "context_overflow", type: "context_overflow",
message: m, message: m,
+1 -1
View File
@@ -47,7 +47,7 @@ import { ProviderTransform } from "./transform"
import { Installation } from "../installation" import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema" import { ModelID, ProviderID } from "./schema"
const DEFAULT_CHUNK_TIMEOUT = 300_000 const DEFAULT_CHUNK_TIMEOUT = 120_000
export namespace Provider { export namespace Provider {
const log = Log.create({ service: "provider" }) const log = Log.create({ service: "provider" })
+31 -35
View File
@@ -167,44 +167,40 @@ export namespace Pty {
subscribers: new Map(), subscribers: new Map(),
} }
state().set(id, session) state().set(id, session)
ptyProcess.onData( ptyProcess.onData((chunk) => {
Instance.bind((chunk) => { session.cursor += chunk.length
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) { for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) { if (ws.readyState !== 1) {
session.subscribers.delete(key) session.subscribers.delete(key)
continue continue
}
if (ws.data !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
} }
session.buffer += chunk if (ws.data !== key) {
if (session.buffer.length <= BUFFER_LIMIT) return session.subscribers.delete(key)
const excess = session.buffer.length - BUFFER_LIMIT continue
session.buffer = session.buffer.slice(excess) }
session.bufferCursor += excess
}), try {
) ws.send(chunk)
ptyProcess.onExit( } catch {
Instance.bind(({ exitCode }) => { session.subscribers.delete(key)
if (session.info.status === "exited") return }
log.info("session exited", { id, exitCode }) }
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode }) session.buffer += chunk
remove(id) 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)
})
Bus.publish(Event.Created, { info }) Bus.publish(Event.Created, { info })
return info return info
} }
+149 -21
View File
@@ -1,39 +1,167 @@
import { runPromiseInstance } from "@/effect/runtime" import { Bus } from "@/bus"
import * as S from "./service" import { BusEvent } from "@/bus/bus-event"
import type { QuestionID } from "./schema" import { SessionID, MessageID } from "@/session/schema"
import type { SessionID, MessageID } from "@/session/schema" import { Instance } from "@/project/instance"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace Question { export namespace Question {
export const Option = S.Option const log = Log.create({ service: "question" })
export type Option = S.Option
export const Info = S.Info export const Option = z
export type Info = S.Info .object({
export const Request = S.Request label: z.string().describe("Display text (1-5 words, concise)"),
export type Request = S.Request description: z.string().describe("Explanation of choice"),
export const Answer = S.Answer })
export type Answer = S.Answer .meta({
export const Reply = S.Reply ref: "QuestionOption",
export type Reply = S.Reply })
export const Event = S.Event export type Option = z.infer<typeof Option>
export const RejectedError = S.RejectedError
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({
ref: "QuestionInfo",
})
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "QuestionRequest",
})
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({
ref: "QuestionAnswer",
})
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z
.array(Answer)
.describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
interface PendingEntry {
info: Request
resolve: (answers: Answer[]) => void
reject: (e: any) => void
}
const state = Instance.state(async () => ({
pending: new Map<QuestionID, PendingEntry>(),
}))
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<Answer[]> {
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) const s = await state()
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
return new Promise<Answer[]>((resolve, reject) => {
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
s.pending.set(id, {
info,
resolve,
reject,
})
Bus.publish(Event.Asked, info)
})
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> { export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) const s = await state()
const existing = s.pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
s.pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
existing.resolve(input.answers)
} }
export async function reject(requestID: QuestionID): Promise<void> { export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) const s = await state()
const existing = s.pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
s.pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
existing.reject(new RejectedError())
} }
export async function list(): Promise<Request[]> { export class RejectedError extends Error {
return runPromiseInstance(S.QuestionService.use((service) => service.list())) constructor() {
super("The user dismissed this question")
}
}
export async function list() {
return state().then((x) => Array.from(x.pending.values(), (x) => x.info))
} }
} }
+10 -10
View File
@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema" import { withStatics } from "@/util/schema"
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) { const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID"))
static make(id: string): QuestionID {
return this.makeUnsafe(id)
}
static ascending(id?: string): QuestionID { export type QuestionID = typeof questionIdSchema.Type
return this.makeUnsafe(Identifier.ascending("question", id))
}
static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID> export const QuestionID = questionIdSchema.pipe(
} withStatics((schema: typeof questionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)),
zod: Identifier.schema("question").pipe(z.custom<QuestionID>()),
})),
)
-172
View File
@@ -1,172 +0,0 @@
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 { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
// --- Zod schemas (re-exported by facade) ---
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
// --- Effect service ---
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export namespace QuestionService {
export interface Service {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
"@opencode/Question",
) {
static readonly layer = Layer.effect(
QuestionService,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("QuestionService.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
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 existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("QuestionService.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return QuestionService.of({ ask, reply, reject, list })
}),
)
}
+2 -3
View File
@@ -14,8 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format" import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs, VcsService } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill" import { Skill } from "../skill/skill"
import { Auth } from "../auth" import { Auth } from "../auth"
@@ -331,7 +330,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) const branch = await Vcs.branch()
return c.json({ return c.json({
branch, branch,
}) })
+1 -3
View File
@@ -200,8 +200,6 @@ When constructing the summary, try to stick to this template:
---` ---`
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") 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({ const result = await processor.process({
user: userMessage, user: userMessage,
agent, agent,
@@ -210,7 +208,7 @@ When constructing the summary, try to stick to this template:
tools: {}, tools: {},
system: [], system: [],
messages: [ messages: [
...MessageV2.toModelMessages(msgs, model, { stripMedia: true }), ...MessageV2.toModelMessages(messages, model, { stripMedia: true }),
{ {
role: "user", role: "user",
content: [ content: [
+2 -6
View File
@@ -32,7 +32,6 @@ export namespace LLM {
sessionID: string sessionID: string
model: Provider.Model model: Provider.Model
agent: Agent.Info agent: Agent.Info
permission?: PermissionNext.Ruleset
system: string[] system: string[]
abort: AbortSignal abort: AbortSignal
messages: ModelMessage[] messages: ModelMessage[]
@@ -256,11 +255,8 @@ export namespace LLM {
}) })
} }
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) { async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const disabled = PermissionNext.disabled( const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
Object.keys(input.tools),
PermissionNext.merge(input.agent.permission, input.permission ?? []),
)
for (const tool of Object.keys(input.tools)) { for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) {
delete input.tools[tool] delete input.tools[tool]
+1 -2
View File
@@ -666,7 +666,6 @@ export namespace SessionPrompt {
const result = await processor.process({ const result = await processor.process({
user: lastUser, user: lastUser,
agent, agent,
permission: session.permission,
abort, abort,
sessionID, sessionID,
system, system,
@@ -1245,7 +1244,7 @@ export namespace SessionPrompt {
] ]
} }
await FileTime.read(input.sessionID, filepath) FileTime.read(input.sessionID, filepath)
return [ return [
{ {
messageID: info.id, messageID: info.id,
+26 -23
View File
@@ -1,38 +1,41 @@
import { Schema } from "effect" import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id"
import { withStatics } from "@/util/schema" import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
export const SessionID = Schema.String.pipe( const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID"))
Schema.brand("SessionID"),
withStatics((s) => ({ export type SessionID = typeof sessionIdSchema.Type
make: (id: string) => s.makeUnsafe(id),
descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)), export const SessionID = sessionIdSchema.pipe(
zod: Identifier.schema("session").pipe(z.custom<Schema.Schema.Type<typeof s>>()), withStatics((schema: typeof sessionIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)),
zod: Identifier.schema("session").pipe(z.custom<SessionID>()),
})), })),
) )
export type SessionID = Schema.Schema.Type<typeof SessionID> const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID"))
export const MessageID = Schema.String.pipe( export type MessageID = typeof messageIdSchema.Type
Schema.brand("MessageID"),
withStatics((s) => ({ export const MessageID = messageIdSchema.pipe(
make: (id: string) => s.makeUnsafe(id), withStatics((schema: typeof messageIdSchema) => ({
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)), make: (id: string) => schema.makeUnsafe(id),
zod: Identifier.schema("message").pipe(z.custom<Schema.Schema.Type<typeof s>>()), ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)),
zod: Identifier.schema("message").pipe(z.custom<MessageID>()),
})), })),
) )
export type MessageID = Schema.Schema.Type<typeof MessageID> const partIdSchema = Schema.String.pipe(Schema.brand("PartID"))
export const PartID = Schema.String.pipe( export type PartID = typeof partIdSchema.Type
Schema.brand("PartID"),
withStatics((s) => ({ export const PartID = partIdSchema.pipe(
make: (id: string) => s.makeUnsafe(id), withStatics((schema: typeof partIdSchema) => ({
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)), make: (id: string) => schema.makeUnsafe(id),
zod: Identifier.schema("part").pipe(z.custom<Schema.Schema.Type<typeof s>>()), ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)),
zod: Identifier.schema("part").pipe(z.custom<PartID>()),
})), })),
) )
export type PartID = Schema.Schema.Type<typeof PartID>
+91 -111
View File
@@ -1,118 +1,98 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node" import path from "path"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" import { mkdir } from "fs/promises"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ export namespace Discovery {
name: Schema.String, const log = Log.create({ service: "skill-discovery" })
files: Schema.Array(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({ type Index = {
skills: Schema.Array(IndexSkill), skills: Array<{
}) {} name: string
description: string
files: string[]
}>
}
const skillConcurrency = 4 export function dir() {
const fileConcurrency = 8 return path.join(Global.Path.cache, "skills")
}
export namespace DiscoveryService { async function get(url: string, dest: string): Promise<boolean> {
export interface Service { if (await Filesystem.exists(dest)) return true
readonly pull: (url: string) => Effect.Effect<string[]> 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 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),
)
}
+142 -191
View File
@@ -10,25 +10,15 @@ import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { DiscoveryService } from "./discovery" import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next" 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 { export namespace Skill {
const log = Log.create({ service: "skill" })
export const Info = z.object({ export const Info = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -55,20 +45,155 @@ 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) { export async function get(name: string) {
return runPromiseInstance(SkillService.use((s) => s.get(name))) return state().then((x) => x.skills[name])
} }
export async function all() { export async function all() {
return runPromiseInstance(SkillService.use((s) => s.all())) return state().then((x) => Object.values(x.skills))
} }
export async function dirs() { export async function dirs() {
return runPromiseInstance(SkillService.use((s) => s.dirs())) return state().then((x) => x.dirs)
} }
export async function available(agent?: Agent.Info) { export async function available(agent?: Agent.Info) {
return runPromiseInstance(SkillService.use((s) => s.available(agent))) const list = await all()
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
} }
export function fmt(list: Info[], opts: { verbose: boolean }) { export function fmt(list: Info[], opts: { verbose: boolean }) {
@@ -91,177 +216,3 @@ export namespace Skill {
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") 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, file: filePath,
event: existed ? "change" : "add", event: existed ? "change" : "add",
}) })
await FileTime.read(ctx.sessionID, filePath) FileTime.read(ctx.sessionID, filePath)
return return
} }
@@ -119,7 +119,7 @@ export const EditTool = Tool.define("edit", {
diff = trimDiff( diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
) )
await FileTime.read(ctx.sessionID, filePath) FileTime.read(ctx.sessionID, filePath)
}) })
const filediff: Snapshot.FileDiff = { const filediff: Snapshot.FileDiff = {
+1 -1
View File
@@ -214,7 +214,7 @@ export const ReadTool = Tool.define("read", {
// just warms the lsp client // just warms the lsp client
LSP.touchFile(filepath, false) LSP.touchFile(filepath, false)
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) { if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>` 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, file: filepath,
event: exists ? "change" : "add", event: exists ? "change" : "add",
}) })
await FileTime.read(ctx.sessionID, filepath) FileTime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully." let output = "Wrote file successfully."
await LSP.touchFile(filepath, true) await LSP.touchFile(filepath, true)
-92
View File
@@ -1,92 +0,0 @@
import { Schema, SchemaAST } from "effect"
import z from "zod"
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const out = body(ast)
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast)
switch (ast._tag) {
case "String":
return z.string()
case "Number":
return z.number()
case "Boolean":
return z.boolean()
case "Null":
return z.null()
case "Undefined":
return z.undefined()
case "Any":
case "Unknown":
return z.unknown()
case "Never":
return z.never()
case "Literal":
return z.literal(ast.literal)
case "Union":
return union(ast)
case "Objects":
return object(ast)
case "Arrays":
return array(ast)
case "Declaration":
return decl(ast)
default:
return fail(ast)
}
}
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
if (items.length === 1) return walk(items[0]).optional()
if (items.length > 1)
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
return z.undefined().optional()
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
const items = ast.types.map(walk)
if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast)
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type))
}
if (ast.indexSignatures.length > 0) return fail(ast)
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
if (ast.elements.length > 0) return fail(ast)
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
if (ast.typeParameters.length !== 1) return fail(ast)
return walk(ast.typeParameters[0])
}
function fail(ast: SchemaAST.AST): never {
const ref = SchemaAST.resolveIdentifier(ast)
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
}
@@ -0,0 +1,51 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance } from "@/project/instance"
const TypeId = Symbol.for("@opencode/InstanceState")
type Task = (key: string) => Effect.Effect<void>
const tasks = new Set<Task>()
export namespace InstanceState {
export interface State<A, E = never, R = never> {
readonly [TypeId]: typeof TypeId
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
}
export const make = <A, E = never, R = never>(input: {
lookup: (key: string) => Effect.Effect<A, E, R>
release?: (value: A, key: string) => Effect.Effect<void>
}): Effect.Effect<State<A, E, R>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY,
lookup: (key) =>
Effect.acquireRelease(input.lookup(key), (value) =>
input.release ? input.release(value, key) : Effect.void,
),
})
const task: Task = (key) => ScopedCache.invalidate(cache, key)
tasks.add(task)
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
return {
[TypeId]: TypeId,
cache,
}
})
export const get = <A, E, R>(self: State<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
export const has = <A, E, R>(self: State<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
export const invalidate = <A, E, R>(self: State<A, E, R>) => ScopedCache.invalidate(self.cache, Instance.directory)
export const dispose = (key: string) =>
Effect.all(
[...tasks].map((task) => task(key)),
{ concurrency: "unbounded" },
)
}
-36
View File
@@ -15,39 +15,3 @@ export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) => <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M => (schema: S): S & M =>
Object.assign(schema, methods(schema)) Object.assign(schema, methods(schema))
declare const NewtypeBrand: unique symbol
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
/**
* Nominal wrapper for scalar types. The class itself is a valid schema
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
*
* @example
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
* static make(id: string): QuestionID {
* return this.makeUnsafe(id)
* }
* }
*
* Schema.decodeEffect(QuestionID)(input)
*/
export function Newtype<Self>() {
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
type Branded = NewtypeBrand<Tag>
abstract class Base {
declare readonly [NewtypeBrand]: Tag
static makeUnsafe(value: Schema.Schema.Type<S>): Self {
return value as unknown as Self
}
}
Object.setPrototypeOf(Base, schema)
return Base as unknown as (abstract new (_: never) => Branded) & {
readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self
} & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
}
}
-466
View File
@@ -1,5 +1,4 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { $ } from "bun"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { File } from "../../src/file" import { File } from "../../src/file"
@@ -392,469 +391,4 @@ 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()
},
})
})
})
}) })
+84 -31
View File
@@ -1,16 +1,13 @@
import { describe, test, expect, afterEach } from "bun:test" import { describe, test, expect, beforeEach } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
afterEach(() => Instance.disposeAll())
describe("file/time", () => { describe("file/time", () => {
const sessionID = SessionID.make("ses_00000000000000000000000001") const sessionID = "test-session-123"
describe("read() and get()", () => { describe("read() and get()", () => {
test("stores read timestamp", async () => { test("stores read timestamp", async () => {
@@ -21,13 +18,12 @@ describe("file/time", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const before = await FileTime.get(sessionID, filepath) const before = FileTime.get(sessionID, filepath)
expect(before).toBeUndefined() expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const after = await FileTime.get(sessionID, filepath) const after = FileTime.get(sessionID, filepath)
expect(after).toBeInstanceOf(Date) expect(after).toBeInstanceOf(Date)
expect(after!.getTime()).toBeGreaterThan(0) expect(after!.getTime()).toBeGreaterThan(0)
}, },
@@ -42,12 +38,11 @@ describe("file/time", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) FileTime.read("session1", filepath)
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) FileTime.read("session2", filepath)
await Bun.sleep(10)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) const time1 = FileTime.get("session1", filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) const time2 = FileTime.get("session2", filepath)
expect(time1).toBeDefined() expect(time1).toBeDefined()
expect(time2).toBeDefined() expect(time2).toBeDefined()
@@ -64,16 +59,14 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10) const first = FileTime.get(sessionID, filepath)!
const first = await FileTime.get(sessionID, filepath)
await Bun.sleep(10) await new Promise((resolve) => setTimeout(resolve, 10))
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10) const second = FileTime.get(sessionID, filepath)!
const second = await FileTime.get(sessionID, filepath)
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
}, },
}) })
}) })
@@ -89,7 +82,8 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
// Should not throw
await FileTime.assert(sessionID, filepath) await FileTime.assert(sessionID, filepath)
}, },
}) })
@@ -117,8 +111,13 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(100)
// Wait to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 100))
// Modify file after reading
await fs.writeFile(filepath, "modified content", "utf-8") await fs.writeFile(filepath, "modified content", "utf-8")
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
}, },
}) })
@@ -132,8 +131,8 @@ describe("file/time", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(100) await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8") await fs.writeFile(filepath, "modified", "utf-8")
let error: Error | undefined let error: Error | undefined
@@ -148,6 +147,28 @@ 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()", () => { describe("withLock()", () => {
@@ -194,7 +215,7 @@ describe("file/time", () => {
const op1 = FileTime.withLock(filepath, async () => { const op1 = FileTime.withLock(filepath, async () => {
order.push(1) order.push(1)
await Bun.sleep(50) await new Promise((resolve) => setTimeout(resolve, 10))
order.push(2) order.push(2)
}) })
@@ -204,7 +225,12 @@ describe("file/time", () => {
}) })
await Promise.all([op1, op2]) await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
// Operations should be serialized
expect(order).toContain(1)
expect(order).toContain(2)
expect(order).toContain(3)
expect(order).toContain(4)
}, },
}) })
}) })
@@ -222,8 +248,8 @@ describe("file/time", () => {
const op1 = FileTime.withLock(filepath1, async () => { const op1 = FileTime.withLock(filepath1, async () => {
started1 = true started1 = true
await Bun.sleep(50) await new Promise((resolve) => setTimeout(resolve, 50))
expect(started2).toBe(true) expect(started2).toBe(true) // op2 should have started while op1 is running
}) })
const op2 = FileTime.withLock(filepath2, async () => { const op2 = FileTime.withLock(filepath2, async () => {
@@ -231,6 +257,7 @@ describe("file/time", () => {
}) })
await Promise.all([op1, op2]) await Promise.all([op1, op2])
expect(started1).toBe(true) expect(started1).toBe(true)
expect(started2).toBe(true) expect(started2).toBe(true)
}, },
@@ -250,6 +277,7 @@ describe("file/time", () => {
}), }),
).rejects.toThrow("Test error") ).rejects.toThrow("Test error")
// Lock should be released, subsequent operations should work
let executed = false let executed = false
await FileTime.withLock(filepath, async () => { await FileTime.withLock(filepath, async () => {
executed = true executed = true
@@ -258,6 +286,31 @@ 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", () => { describe("stat() Filesystem.stat pattern", () => {
@@ -270,12 +323,12 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const stats = Filesystem.stat(filepath) const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date) expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0) expect(stats!.mtime.getTime()).toBeGreaterThan(0)
// FileTime.assert uses this stat internally
await FileTime.assert(sessionID, filepath) await FileTime.assert(sessionID, filepath)
}, },
}) })
@@ -290,11 +343,11 @@ describe("file/time", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) FileTime.read(sessionID, filepath)
await Bun.sleep(10)
const originalStat = Filesystem.stat(filepath) const originalStat = Filesystem.stat(filepath)
await Bun.sleep(100) // Wait and modify
await new Promise((resolve) => setTimeout(resolve, 100))
await fs.writeFile(filepath, "modified", "utf-8") await fs.writeFile(filepath, "modified", "utf-8")
const newStat = Filesystem.stat(filepath) const newStat = Filesystem.stat(filepath)
-236
View File
@@ -1,236 +0,0 @@
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)
}),
),
),
)
})
})
@@ -1,47 +0,0 @@
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()
}
},
})
}
@@ -1,64 +0,0 @@
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()))
})
})
})
+2 -343
View File
@@ -1,38 +1,10 @@
import { afterEach, test, expect } from "bun:test" import { test, expect } from "bun:test"
import os from "os" 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 { PermissionNext } from "../../src/permission/next"
import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { MessageID, SessionID } from "../../src/session/schema" import { 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({
requestID: req.id,
reply: "reject",
message,
})
}
}
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
const list = await PermissionNext.list()
if (list.length === count) return list
await Bun.sleep(0)
}
return PermissionNext.list()
}
// fromConfig tests // fromConfig tests
@@ -539,84 +511,6 @@ test("ask - returns pending promise when action is ask", async () => {
// Promise should be pending, not resolved // Promise should be pending, not resolved
expect(promise).toBeInstanceOf(Promise) expect(promise).toBeInstanceOf(Promise)
// Don't await - just verify it returns a promise // Don't await - just verify it returns a promise
await rejectAll()
await promise.catch(() => {})
},
})
})
test("ask - adds request to pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
})
const list = await PermissionNext.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
})
await rejectAll()
await ask.catch(() => {})
},
})
})
test("ask - publishes asked event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
let seen: PermissionNext.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
seen = event.properties
})
const ask = PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
tool: {
messageID: MessageID.make("msg_test"),
callID: "call_test",
},
ruleset: [],
})
expect(await PermissionNext.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
})
unsub()
await rejectAll()
await ask.catch(() => {})
}, },
}) })
}) })
@@ -638,8 +532,6 @@ test("reply - once resolves the pending ask", async () => {
ruleset: [], ruleset: [],
}) })
await waitForPending(1)
await PermissionNext.reply({ await PermissionNext.reply({
requestID: PermissionID.make("per_test1"), requestID: PermissionID.make("per_test1"),
reply: "once", reply: "once",
@@ -665,8 +557,6 @@ test("reply - reject throws RejectedError", async () => {
ruleset: [], ruleset: [],
}) })
await waitForPending(1)
await PermissionNext.reply({ await PermissionNext.reply({
requestID: PermissionID.make("per_test2"), requestID: PermissionID.make("per_test2"),
reply: "reject", reply: "reject",
@@ -677,36 +567,6 @@ test("reply - reject throws RejectedError", async () => {
}) })
}) })
test("reply - reject with message throws CorrectedError", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(1)
await PermissionNext.reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
})
test("reply - always persists approval and resolves", async () => { test("reply - always persists approval and resolves", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
@@ -722,8 +582,6 @@ test("reply - always persists approval and resolves", async () => {
ruleset: [], ruleset: [],
}) })
await waitForPending(1)
await PermissionNext.reply({ await PermissionNext.reply({
requestID: PermissionID.make("per_test3"), requestID: PermissionID.make("per_test3"),
reply: "always", reply: "always",
@@ -775,8 +633,6 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [], ruleset: [],
}) })
await waitForPending(2)
// Catch rejections before they become unhandled // Catch rejections before they become unhandled
const result1 = askPromise1.catch((e) => e) const result1 = askPromise1.catch((e) => e)
const result2 = askPromise2.catch((e) => e) const result2 = askPromise2.catch((e) => e)
@@ -794,144 +650,6 @@ test("reply - reject cancels all pending for same session", async () => {
}) })
}) })
test("reply - always resolves matching pending requests in same session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
const b = PermissionNext.ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(2)
await PermissionNext.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("reply - always keeps other session pending", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: ["ls"],
ruleset: [],
})
const b = PermissionNext.ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(2)
await PermissionNext.reply({
requestID: PermissionID.make("per_test6a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll()
await b.catch(() => {})
},
})
})
test("reply - publishes replied event", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [],
})
await waitForPending(1)
let seen:
| {
sessionID: SessionID
requestID: PermissionID
reply: PermissionNext.Reply
}
| undefined
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
seen = event.properties
})
await PermissionNext.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
await expect(ask).resolves.toBeUndefined()
expect(seen).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
reply: "once",
})
unsub()
},
})
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await PermissionNext.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("ask - checks all patterns and stops on first deny", async () => { test("ask - checks all patterns and stops on first deny", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
@@ -971,62 +689,3 @@ test("ask - allows all patterns when all match allow rules", async () => {
}, },
}) })
}) })
test("ask - should deny even when an earlier pattern is ask", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const err = await PermissionNext.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
metadata: {},
always: [],
ruleset: [
{ permission: "bash", pattern: "echo *", action: "ask" },
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}).then(
() => undefined,
(err) => err,
)
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0)
},
})
})
test("ask - abort should clear pending request", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
S.PermissionService.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
metadata: {},
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal },
)
await waitForPending(1)
ctl.abort()
await ask.catch(() => {})
try {
expect(await PermissionNext.list()).toHaveLength(0)
} finally {
await rejectAll()
}
},
})
})
+1 -47
View File
@@ -23,7 +23,7 @@ mock.module("../../src/util/git", () => ({
mode === "rev-list-fail" && mode === "rev-list-fail" &&
cmd.includes("git rev-list") && cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") && cmd.includes("--max-parents=0") &&
cmd.includes("HEAD") cmd.includes("--all")
) { ) {
return Promise.resolve({ return Promise.resolve({
exitCode: 128, exitCode: 128,
@@ -172,52 +172,6 @@ describe("Project.fromDirectory with worktrees", () => {
} }
}) })
test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
const { project: main } = await p.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await p.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Filesystem.exists(cache)
expect(exists).toBe(true)
} finally {
await $`git worktree remove ${worktreePath}`
.cwd(tmp.path)
.quiet()
.catch(() => {})
}
})
test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory
const bare = tmp.path + "-bare"
const clone = tmp.path + "-clone"
try {
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await p.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone)
expect(b.id).toBe(a.id)
} finally {
await $`rm -rf ${bare} ${clone}`.quiet().nothrow()
}
})
test("should accumulate multiple worktrees in sandboxes", async () => { test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject() const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
-117
View File
@@ -1,117 +0,0 @@
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)
})
})
})
@@ -0,0 +1,20 @@
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 { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
const wait = async (fn: () => boolean, ms = 5000) => { const wait = async (fn: () => boolean, ms = 2000) => {
const end = Date.now() + ms const end = Date.now() + ms
while (Date.now() < end) { while (Date.now() < end) {
if (fn()) return if (fn()) return
@@ -20,7 +20,7 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>,
} }
describe("pty", () => { describe("pty", () => {
test("publishes created, exited, deleted in order for a short-lived process", async () => { test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
if (process.platform === "win32") return if (process.platform === "win32") return
await using dir = await tmpdir({ git: true }) await using dir = await tmpdir({ git: true })
@@ -37,11 +37,7 @@ describe("pty", () => {
let id: PtyID | undefined let id: PtyID | undefined
try { try {
const info = await Pty.create({ const info = await Pty.create({ command: "/bin/ls", title: "ls" })
command: "/usr/bin/env",
args: ["sh", "-c", "sleep 0.1"],
title: "sleep",
})
id = info.id id = info.id
await wait(() => pick(log, id!).includes("exited")) await wait(() => pick(log, id!).includes("exited"))
@@ -1,22 +1,10 @@
import { afterEach, test, expect } from "bun:test" import { test, expect } from "bun:test"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema" 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()
for (const req of pending) {
await Question.reject(req.id)
}
}
test("ask - returns pending promise", async () => { test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await Instance.provide({ await Instance.provide({
@@ -36,8 +24,6 @@ test("ask - returns pending promise", async () => {
], ],
}) })
expect(promise).toBeInstanceOf(Promise) expect(promise).toBeInstanceOf(Promise)
await rejectAll()
await promise.catch(() => {})
}, },
}) })
}) })
@@ -58,7 +44,7 @@ test("ask - adds to pending list", async () => {
}, },
] ]
const askPromise = Question.ask({ Question.ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) })
@@ -66,8 +52,6 @@ test("ask - adds to pending list", async () => {
const pending = await Question.list() const pending = await Question.list()
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions) expect(pending[0].questions).toEqual(questions)
await rejectAll()
await askPromise.catch(() => {})
}, },
}) })
}) })
@@ -114,7 +98,7 @@ test("reply - removes from pending list", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const askPromise = Question.ask({ Question.ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -135,7 +119,6 @@ test("reply - removes from pending list", async () => {
requestID: pending[0].id, requestID: pending[0].id,
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
await askPromise
const pendingAfter = await Question.list() const pendingAfter = await Question.list()
expect(pendingAfter.length).toBe(0) expect(pendingAfter.length).toBe(0)
@@ -279,7 +262,7 @@ test("list - returns all pending requests", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const p1 = Question.ask({ Question.ask({
sessionID: SessionID.make("ses_test1"), sessionID: SessionID.make("ses_test1"),
questions: [ questions: [
{ {
@@ -290,7 +273,7 @@ test("list - returns all pending requests", async () => {
], ],
}) })
const p2 = Question.ask({ Question.ask({
sessionID: SessionID.make("ses_test2"), sessionID: SessionID.make("ses_test2"),
questions: [ questions: [
{ {
@@ -303,9 +286,6 @@ test("list - returns all pending requests", async () => {
const pending = await Question.list() const pending = await Question.list()
expect(pending.length).toBe(2) expect(pending.length).toBe(2)
await rejectAll()
p1.catch(() => {})
p2.catch(() => {})
}, },
}) })
}) })
+1 -91
View File
@@ -1,7 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { tool, type ModelMessage } from "ai" import type { ModelMessage } from "ai"
import z from "zod"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session/llm"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -326,95 +325,6 @@ 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 () => { test("sends responses API payload for OpenAI models", async () => {
const server = state.server const server = state.server
if (!server) { if (!server) {
@@ -869,26 +869,6 @@ 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", () => { test("does not classify 429 no body as context overflow", () => {
const result = MessageV2.fromError( const result = MessageV2.fromError(
new APICallError({ new APICallError({
+12 -18
View File
@@ -1,7 +1,5 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery"
import { DiscoveryService } from "../../src/skill/discovery"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { rm } from "fs/promises" import { rm } from "fs/promises"
import path from "path" import path from "path"
@@ -11,10 +9,9 @@ let server: ReturnType<typeof Bun.serve>
let downloadCount = 0 let downloadCount = 0
const fixturePath = path.join(import.meta.dir, "../fixture/skills") const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
beforeAll(async () => { beforeAll(async () => {
await rm(cacheDir, { recursive: true, force: true }) await rm(Discovery.dir(), { recursive: true, force: true })
server = Bun.serve({ server = Bun.serve({
port: 0, port: 0,
@@ -43,25 +40,22 @@ beforeAll(async () => {
afterAll(async () => { afterAll(async () => {
server?.stop() server?.stop()
await rm(cacheDir, { recursive: true, force: true }) await rm(Discovery.dir(), { recursive: true, force: true })
}) })
describe("Discovery.pull", () => { 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 () => { test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL) const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(dirs.length).toBeGreaterThan(0) expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) { for (const dir of dirs) {
expect(dir).toStartWith(cacheDir) expect(dir).toStartWith(Discovery.dir())
const md = path.join(dir, "SKILL.md") const md = path.join(dir, "SKILL.md")
expect(await Filesystem.exists(md)).toBe(true) expect(await Filesystem.exists(md)).toBe(true)
} }
}) })
test("url without trailing slash works", async () => { test("url without trailing slash works", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
expect(dirs.length).toBeGreaterThan(0) expect(dirs.length).toBeGreaterThan(0)
for (const dir of dirs) { for (const dir of dirs) {
const md = path.join(dir, "SKILL.md") const md = path.join(dir, "SKILL.md")
@@ -70,18 +64,18 @@ describe("Discovery.pull", () => {
}) })
test("returns empty array for invalid url", async () => { test("returns empty array for invalid url", async () => {
const dirs = await pull(`http://localhost:${server.port}/invalid-url/`) const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`)
expect(dirs).toEqual([]) expect(dirs).toEqual([])
}) })
test("returns empty array for non-json response", async () => { test("returns empty array for non-json response", async () => {
// any url not explicitly handled in server returns 404 text "Not Found" // any url not explicitly handled in server returns 404 text "Not Found"
const dirs = await pull(`http://localhost:${server.port}/some-other-path/`) const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`)
expect(dirs).toEqual([]) expect(dirs).toEqual([])
}) })
test("downloads reference files alongside SKILL.md", async () => { test("downloads reference files alongside SKILL.md", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL) const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
// find a skill dir that should have reference files (e.g. agents-sdk) // find a skill dir that should have reference files (e.g. agents-sdk)
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
expect(agentsSdk).toBeDefined() expect(agentsSdk).toBeDefined()
@@ -96,17 +90,17 @@ describe("Discovery.pull", () => {
test("caches downloaded files on second pull", async () => { test("caches downloaded files on second pull", async () => {
// clear dir and downloadCount // clear dir and downloadCount
await rm(cacheDir, { recursive: true, force: true }) await rm(Discovery.dir(), { recursive: true, force: true })
downloadCount = 0 downloadCount = 0
// first pull to populate cache // first pull to populate cache
const first = await pull(CLOUDFLARE_SKILLS_URL) const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(first.length).toBeGreaterThan(0) expect(first.length).toBeGreaterThan(0)
const firstCount = downloadCount const firstCount = downloadCount
expect(firstCount).toBeGreaterThan(0) expect(firstCount).toBeGreaterThan(0)
// second pull should return same results from cache // second pull should return same results from cache
const second = await pull(CLOUDFLARE_SKILLS_URL) const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
expect(second.length).toBe(first.length) expect(second.length).toBe(first.length)
expect(second.sort()).toEqual(first.sort()) expect(second.sort()).toEqual(first.sort())

Some files were not shown because too many files have changed in this diff Show More