Compare commits

..

5 Commits

Author SHA1 Message Date
Shoubhit Dash
2f81e302f8 Merge branch 'dev' into review-git-changes 2026-03-18 15:51:23 +05:30
Shoubhit Dash
58e26ac413 refactor(opencode): parse vcs status with porcelain 2026-03-18 15:49:20 +05:30
Shoubhit Dash
352af13d3a Merge branch 'dev' into review-git-changes 2026-03-18 00:31:41 +05:30
Shoubhit Dash
948138f385 feat(app): add git review modes 2026-03-17 19:43:44 +05:30
Shoubhit Dash
fbb074480d feat(opencode): add vcs diff api 2026-03-17 19:43:24 +05:30
146 changed files with 4087 additions and 4486 deletions

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3.1-pro
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.

View File

@@ -46,7 +46,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
@@ -227,7 +227,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
@@ -324,7 +324,8 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@effect/platform-node": "4.0.0-beta.31",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -351,13 +352,11 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -402,7 +401,6 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
@@ -594,7 +592,6 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.35",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
@@ -618,7 +615,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.35",
"effect": "4.0.0-beta.31",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -976,9 +973,9 @@
"@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.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
"@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.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
"@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=="],
@@ -1108,6 +1105,8 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -2060,8 +2059,6 @@
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -2750,7 +2747,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3026,8 +3023,6 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-7uUbITj7tqNF+AG4AgVEYpkIsXGCCt0BLHULghaXktyP7DOqqMYc3967AnbYZQW04uC8MXeAxCCaaWZ/frwk3A=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -5020,8 +5015,6 @@
"@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/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
"@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=="],
@@ -5054,6 +5047,10 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -5448,10 +5445,6 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
}
}

View File

@@ -25,7 +25,6 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,7 +44,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.35",
"effect": "4.0.0-beta.31",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",

View File

@@ -7,18 +7,12 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.focus()
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line one")
await expect(prompt).toBeFocused()
await prompt.press("Shift+Enter")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toBeFocused()
await prompt.pressSequentially("line two")
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})

View File

@@ -56,7 +56,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -274,11 +274,12 @@ export function SessionHeader() {
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),

View File

@@ -65,11 +65,14 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorName = (err: unknown) => {
const errorStatus = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
if (!("data" in err)) return
const data = err.data
if (!data || typeof data !== "object") return
if (!("statusCode" in data)) return
const status = data.statusCode
return typeof status === "number" ? status : undefined
}
const useTerminalUiBindings = (input: {
@@ -478,7 +481,7 @@ export const Terminal = (props: TerminalProps) => {
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
if (errorStatus(err) === 404) return true
debugTerminal("failed to inspect terminal session", err)
return false
})

View File

@@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: {
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
if (next) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(

View File

@@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: {
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
const props = event.properties as { branch?: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
const next = { ...input.store.vcs, branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break

View File

@@ -532,6 +532,8 @@ export const dict = {
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",

View File

@@ -566,7 +566,6 @@ export default function Layout(props: ParentProps) {
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
if (!untrack(() => state.autoselect)) return
const list = layout.projects.list()
const last = server.projects.last()

View File

@@ -1,4 +1,4 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
batch,
@@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
@@ -415,15 +418,16 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!params.id)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -499,11 +503,22 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
changes: "git" as ChangeMode,
newSessionWorktree: "main",
deferRender: false,
})
const [vcs, setVcs] = createStore({
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
@@ -531,6 +546,40 @@ export default function Page() {
let refreshTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const resetVcs = () => {
vcsTask.clear()
setVcs({
diff: { git: [], branch: [] },
ready: { git: false, branch: false },
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (vcs.ready[mode] && !force) return Promise.resolve()
const current = vcsTask.get(mode)
if (current) return current
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
setVcs("diff", mode, result.data ?? [])
setVcs("ready", mode, true)
})
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -546,7 +595,38 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const git = sync.project?.vcs === "git"
if (git) list.push("git")
if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) {
list.push("branch")
}
list.push("session", "turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -615,10 +695,10 @@ export default function Page() {
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -741,13 +821,23 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "session")
setStore("changes", "git")
setUi("pendingMessage", undefined)
},
{ defer: true },
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
@@ -870,6 +960,38 @@ export default function Page() {
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
},
{ defer: true },
),
)
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -916,21 +1038,23 @@ export default function Page() {
loadFile: file.load,
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
const changesTitle = () => {
if (!hasReview()) {
if (!canReview()) {
return null
}
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
return (
<Select
options={changesOptionsList}
options={changesOptions()}
current={store.changes}
label={(option) =>
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
}
label={label}
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="small"
@@ -939,20 +1063,34 @@ export default function Page() {
)
}
const emptyTurn = () => (
const empty = (text: string) => (
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
</div>
)
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "turn") return emptyTurn()
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
})
if (hasReview() && !diffsReady()) {
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (reviewEmptyKey() === "session.review.noVcs") {
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
@@ -972,7 +1110,7 @@ export default function Page() {
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
</div>
)
}
@@ -1076,7 +1214,7 @@ export default function Page() {
const pending = tree.pendingDiff
if (!pending) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
if (!reviewReady()) return
const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
@@ -1808,6 +1946,12 @@ export default function Page() {
</div>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}

View File

@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
reviewCount: () => number
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const reviewEmptyKey = createMemo(() => {
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
for (const diff of props.diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview,
hasReview: props.canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={hasReview()}>
<div>{reviewCount()}</div>
<Show when={props.hasReview()}>
<div>{props.reviewCount()}</div>
</Show>
</div>
</Tabs.Trigger>
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
</Tabs.List>
</div>
<Show when={reviewTab()}>
<Show when={reviewTab() && props.canReview()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
@@ -377,8 +365,8 @@ export function SessionSidePanel(props: {
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
{props.reviewCount()}{" "}
{language.t(props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
@@ -386,9 +374,9 @@ export function SessionSidePanel(props: {
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={diffsReady()}
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
@@ -408,9 +396,7 @@ export function SessionSidePanel(props: {
</Show>
</Match>
<Match when={true}>
{empty(
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
)}
{empty(props.empty())}
</Match>
</Switch>
</Tabs.Content>

View File

@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync.session.get(id)
}
const hasReview = () => {
const id = params.id
if (!id) return false
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
}
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)

View File

@@ -249,7 +249,7 @@ export const dict = {
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
"go.hero.body":
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
@@ -297,7 +297,7 @@ export const dict = {
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
"go.problem.item2": "حدود سخية ووصول موثوق",
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5",
"go.how.title": "كيف يعمل Go",
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
"go.how.step1.title": "أنشئ حسابًا",
@@ -318,10 +318,10 @@ export const dict = {
"go.faq.q1": "ما هو OpenCode Go؟",
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5، مع حدود سخية ووصول موثوق.",
"go.faq.q3": "هل Go هو نفسه Zen؟",
"go.faq.a3":
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5.",
"go.faq.q4": "كم تكلفة Go؟",
"go.faq.a4.p1.beforePricing": "تكلفة Go",
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
@@ -345,7 +345,7 @@ export const dict = {
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
"go.faq.a9":
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",

View File

@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5 e MiniMax M2.5.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
"go.hero.body":
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
@@ -302,7 +302,7 @@ export const dict = {
"go.problem.item1": "Preço de assinatura de baixo custo",
"go.problem.item2": "Limites generosos e acesso confiável",
"go.problem.item3": "Feito para o maior número possível de programadores",
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
"go.problem.item4": "Inclui GLM-5, Kimi K2.5 e MiniMax M2.5",
"go.how.title": "Como o Go funciona",
"go.how.body":
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
@@ -325,10 +325,10 @@ export const dict = {
"go.faq.a1":
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
"go.faq.q2": "Quais modelos o Go inclui?",
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5, com limites generosos e acesso confiável.",
"go.faq.q3": "O Go é o mesmo que o Zen?",
"go.faq.a3":
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
"go.faq.q4": "Quanto custa o Go?",
"go.faq.a4.p1.beforePricing": "O Go custa",
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
@@ -353,7 +353,7 @@ export const dict = {
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
"go.faq.a9":
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",

View File

@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
"go.hero.body":
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item1": "Lavpris abonnementspriser",
"go.problem.item2": "Generøse grænser og pålidelig adgang",
"go.problem.item3": "Bygget til så mange programmører som muligt",
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
"go.how.title": "Hvordan Go virker",
"go.how.body":
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
@@ -322,11 +322,10 @@ export const dict = {
"go.faq.a1":
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
"go.faq.q2": "Hvilke modeller inkluderer Go?",
"go.faq.a2":
"Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med generøse grænser og pålidelig adgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5 og MiniMax M2.5.",
"go.faq.q4": "Hvad koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -350,7 +349,7 @@ export const dict = {
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",

View File

@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5 und MiniMax M2.5.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
"go.hero.body":
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
@@ -301,7 +301,7 @@ export const dict = {
"go.problem.item1": "Kostengünstiges Abonnement",
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5",
"go.how.title": "Wie Go funktioniert",
"go.how.body":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
@@ -324,11 +324,10 @@ export const dict = {
"go.faq.a1":
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
"go.faq.q2": "Welche Modelle beinhaltet Go?",
"go.faq.a2":
"Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
"go.faq.a2": "Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5, mit großzügigen Limits und zuverlässigem Zugang.",
"go.faq.q3": "Ist Go dasselbe wie Zen?",
"go.faq.a3":
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5 und MiniMax M2.5.",
"go.faq.q4": "Wie viel kostet Go?",
"go.faq.a4.p1.beforePricing": "Go kostet",
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
@@ -353,7 +352,7 @@ export const dict = {
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
"go.faq.a9":
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",

View File

@@ -248,7 +248,7 @@ export const dict = {
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, and MiniMax M2.5.",
"go.hero.title": "Low cost coding models for everyone",
"go.hero.body":
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
@@ -295,7 +295,7 @@ export const dict = {
"go.problem.item1": "Low cost subscription pricing",
"go.problem.item2": "Generous limits and reliable access",
"go.problem.item3": "Built for as many programmers as possible",
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
"go.problem.item4": "Includes GLM-5, Kimi K2.5, and MiniMax M2.5",
"go.how.title": "How Go works",
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
"go.how.step1.title": "Create an account",
@@ -317,11 +317,10 @@ export const dict = {
"go.faq.a1":
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
"go.faq.q2": "What models does Go include?",
"go.faq.a2":
"Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
"go.faq.a2": "Go includes GLM-5, Kimi K2.5, and MiniMax M2.5, with generous limits and reliable access.",
"go.faq.q3": "Is Go the same as Zen?",
"go.faq.a3":
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, and MiniMax M2.5.",
"go.faq.q4": "How much does Go cost?",
"go.faq.a4.p1.beforePricing": "Go costs",
"go.faq.a4.p1.pricingLink": "$5 first month",
@@ -345,7 +344,7 @@ export const dict = {
"go.faq.q9": "What is the difference between free models and Go?",
"go.faq.a9":
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, and MiniMax M2.5 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} not supported",

View File

@@ -254,7 +254,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5 y MiniMax M2.5.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
"go.hero.body":
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
@@ -303,7 +303,7 @@ export const dict = {
"go.problem.item1": "Precios de suscripción de bajo coste",
"go.problem.item2": "Límites generosos y acceso fiable",
"go.problem.item3": "Creado para tantos programadores como sea posible",
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7",
"go.problem.item4": "Incluye GLM-5, Kimi K2.5 y MiniMax M2.5",
"go.how.title": "Cómo funciona Go",
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
"go.how.step1.title": "Crear una cuenta",
@@ -325,10 +325,10 @@ export const dict = {
"go.faq.a1":
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
"go.faq.q2": "¿Qué modelos incluye Go?",
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5, con límites generosos y acceso fiable.",
"go.faq.q3": "¿Es Go lo mismo que Zen?",
"go.faq.a3":
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5 y MiniMax M2.5.",
"go.faq.q4": "¿Cuánto cuesta Go?",
"go.faq.a4.p1.beforePricing": "Go cuesta",
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
@@ -353,7 +353,7 @@ export const dict = {
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
"go.faq.a9":
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",

View File

@@ -255,7 +255,7 @@ export const dict = {
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5 et MiniMax M2.5.",
"go.hero.title": "Modèles de code à faible coût pour tous",
"go.hero.body":
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
@@ -303,7 +303,7 @@ export const dict = {
"go.problem.item1": "Prix d'abonnement bas",
"go.problem.item2": "Limites généreuses et accès fiable",
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7",
"go.problem.item4": "Inclut GLM-5, Kimi K2.5 et MiniMax M2.5",
"go.how.title": "Comment fonctionne Go",
"go.how.body":
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
@@ -326,11 +326,10 @@ export const dict = {
"go.faq.a1":
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
"go.faq.q2": "Quels modèles Go inclut-il ?",
"go.faq.a2":
"Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
"go.faq.a2": "Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5, avec des limites généreuses et un accès fiable.",
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
"go.faq.a3":
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5 et MiniMax M2.5.",
"go.faq.q4": "Combien coûte Go ?",
"go.faq.a4.p1.beforePricing": "Go coûte",
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
@@ -354,7 +353,7 @@ export const dict = {
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
"go.faq.a9":
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",

View File

@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5 e MiniMax M2.5.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
"go.hero.body":
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item1": "Prezzo di abbonamento a basso costo",
"go.problem.item2": "Limiti generosi e accesso affidabile",
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
"go.problem.item4": "Include GLM-5, Kimi K2.5 e MiniMax M2.5",
"go.how.title": "Come funziona Go",
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
"go.how.step1.title": "Crea un account",
@@ -321,10 +321,10 @@ export const dict = {
"go.faq.a1":
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
"go.faq.q2": "Quali modelli include Go?",
"go.faq.a2": "Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
"go.faq.a2": "Go include GLM-5, Kimi K2.5 e MiniMax M2.5, con limiti generosi e accesso affidabile.",
"go.faq.q3": "Go è lo stesso di Zen?",
"go.faq.a3":
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
"go.faq.q4": "Quanto costa Go?",
"go.faq.a4.p1.beforePricing": "Go costa",
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
@@ -349,7 +349,7 @@ export const dict = {
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
"go.faq.a9":
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5 e MiniMax M2.5 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",

View File

@@ -250,7 +250,7 @@ export const dict = {
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5に対して5時間のゆとりあるリクエスト上限があります。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
"go.hero.body":
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item1": "低価格なサブスクリプション料金",
"go.problem.item2": "十分な制限と安定したアクセス",
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7を含む",
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5を含む",
"go.how.title": "Goの仕組み",
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
"go.how.step1.title": "アカウントを作成",
@@ -321,11 +321,10 @@ export const dict = {
"go.faq.a1":
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
"go.faq.q2": "Goにはどのモデルが含まれますか",
"go.faq.a2":
"Goには、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
"go.faq.a2": "Goには、GLM-5、Kimi K2.5、MiniMax M2.5が含まれており、十分な制限と安定したアクセスが提供されます。",
"go.faq.q3": "GoはZenと同じですか",
"go.faq.a3":
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
"go.faq.q4": "Goの料金は",
"go.faq.a4.p1.beforePricing": "Goは",
"go.faq.a4.p1.pricingLink": "最初の月$5",
@@ -350,7 +349,7 @@ export const dict = {
"go.faq.q9": "無料モデルとGoの違いは何ですか",
"go.faq.a9":
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ5時間、週間、月間全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です実際のリクエスト数はモデルと使用状況により異なります。",
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5が含まれ、ローリングウィンドウ5時間、週間、月間全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です実際のリクエスト数はモデルと使用状況により異なります。",
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",

View File

@@ -247,7 +247,7 @@ export const dict = {
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
"go.hero.body":
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
@@ -296,7 +296,7 @@ export const dict = {
"go.problem.item1": "저렴한 구독 가격",
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 포함",
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 포함",
"go.how.title": "Go 작동 방식",
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
"go.how.step1.title": "계정 생성",
@@ -317,11 +317,10 @@ export const dict = {
"go.faq.q1": "OpenCode Go란 무엇인가요?",
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
"go.faq.a2":
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
"go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5가 포함됩니다.",
"go.faq.q3": "Go는 Zen과 같은가요?",
"go.faq.a3":
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
"go.faq.q4": "Go 비용은 얼마인가요?",
"go.faq.a4.p1.beforePricing": "Go 비용은",
"go.faq.a4.p1.pricingLink": "첫 달 $5",
@@ -345,7 +344,7 @@ export const dict = {
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
"go.faq.a9":
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",

View File

@@ -251,7 +251,7 @@ export const dict = {
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
"go.hero.title": "Rimelige kodemodeller for alle",
"go.hero.body":
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
@@ -299,7 +299,7 @@ export const dict = {
"go.problem.item1": "Rimelig abonnementspris",
"go.problem.item2": "Rause grenser og pålitelig tilgang",
"go.problem.item3": "Bygget for så mange programmerere som mulig",
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
"go.how.title": "Hvordan Go fungerer",
"go.how.body":
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
@@ -322,10 +322,10 @@ export const dict = {
"go.faq.a1":
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
"go.faq.q2": "Hvilke modeller inkluderer Go?",
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med rause grenser og pålitelig tilgang.",
"go.faq.q3": "Er Go det samme som Zen?",
"go.faq.a3":
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5 og MiniMax M2.5.",
"go.faq.q4": "Hva koster Go?",
"go.faq.a4.p1.beforePricing": "Go koster",
"go.faq.a4.p1.pricingLink": "$5 første måned",
@@ -350,7 +350,7 @@ export const dict = {
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
"go.faq.a9":
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",

View File

@@ -252,7 +252,7 @@ export const dict = {
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.meta.description":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5 i MiniMax M2.5.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
"go.hero.body":
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
@@ -300,7 +300,7 @@ export const dict = {
"go.problem.item1": "Niskokosztowa cena subskrypcji",
"go.problem.item2": "Hojne limity i niezawodny dostęp",
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7",
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5 i MiniMax M2.5",
"go.how.title": "Jak działa Go",
"go.how.body":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
@@ -323,10 +323,10 @@ export const dict = {
"go.faq.a1":
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
"go.faq.q2": "Jakie modele zawiera Go?",
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5, z hojnymi limitami i niezawodnym dostępem.",
"go.faq.q3": "Czy Go to to samo co Zen?",
"go.faq.a3":
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5 i MiniMax M2.5.",
"go.faq.q4": "Ile kosztuje Go?",
"go.faq.a4.p1.beforePricing": "Go kosztuje",
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
@@ -351,7 +351,7 @@ export const dict = {
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
"go.faq.a9":
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",

View File

@@ -255,7 +255,7 @@ export const dict = {
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.meta.description":
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5 и MiniMax M2.5.",
"go.hero.title": "Недорогие модели для кодинга для всех",
"go.hero.body":
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
@@ -304,7 +304,7 @@ export const dict = {
"go.problem.item1": "Недорогая подписка",
"go.problem.item2": "Щедрые лимиты и надежный доступ",
"go.problem.item3": "Создан для максимального числа программистов",
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7",
"go.problem.item4": "Включает GLM-5, Kimi K2.5 и MiniMax M2.5",
"go.how.title": "Как работает Go",
"go.how.body":
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
@@ -327,10 +327,10 @@ export const dict = {
"go.faq.a1":
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
"go.faq.q2": "Какие модели включает Go?",
"go.faq.a2": "Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
"go.faq.a2": "Go включает GLM-5, Kimi K2.5 и MiniMax M2.5, с щедрыми лимитами и надежным доступом.",
"go.faq.q3": "Go — это то же самое, что и Zen?",
"go.faq.a3":
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5 и MiniMax M2.5.",
"go.faq.q4": "Сколько стоит Go?",
"go.faq.a4.p1.beforePricing": "Go стоит",
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
@@ -355,7 +355,7 @@ export const dict = {
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
"go.faq.a9":
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5 и MiniMax M2.5 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",

View File

@@ -250,7 +250,7 @@ export const dict = {
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5 และ MiniMax M2.5",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.hero.body":
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
@@ -297,7 +297,7 @@ export const dict = {
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5",
"go.how.title": "Go ทำงานอย่างไร",
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
"go.how.step1.title": "สร้างบัญชี",
@@ -319,11 +319,10 @@ export const dict = {
"go.faq.a1":
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
"go.faq.a2":
"Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
"go.faq.a2": "Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
"go.faq.a3":
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5 และ MiniMax M2.5 อย่างเชื่อถือได้",
"go.faq.q4": "Go ราคาเท่าไหร่?",
"go.faq.a4.p1.beforePricing": "Go ราคา",
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
@@ -347,7 +346,7 @@ export const dict = {
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
"go.faq.a9":
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",

View File

@@ -253,7 +253,7 @@ export const dict = {
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5 için cömert 5 saatlik istek limitleri sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
"go.hero.body":
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
@@ -302,7 +302,7 @@ export const dict = {
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 içerir",
"go.problem.item4": "GLM-5, Kimi K2.5 ve MiniMax M2.5 içerir",
"go.how.title": "Go nasıl çalışır?",
"go.how.body":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
@@ -325,11 +325,10 @@ export const dict = {
"go.faq.a1":
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
"go.faq.q2": "Go hangi modelleri içerir?",
"go.faq.a2":
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
"go.faq.a2": "Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini içerir.",
"go.faq.q3": "Go, Zen ile aynı mı?",
"go.faq.a3":
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7ık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5ık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
"go.faq.q4": "Go ne kadar?",
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
"go.faq.a4.p1.pricingLink": "İlk ay $5",
@@ -354,7 +353,7 @@ export const dict = {
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
"go.faq.a9":
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",

View File

@@ -240,8 +240,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情况除外",
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.meta.description":
"Go 首月 $5之后 $10/月,提供对 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
"go.meta.description": "Go 首月 $5之后 $10/月,提供对 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小时充裕请求额度。",
"go.hero.title": "人人可用的低成本编程模型",
"go.hero.body":
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
@@ -288,7 +287,7 @@ export const dict = {
"go.problem.item1": "低成本订阅定价",
"go.problem.item2": "充裕的限额和可靠的访问",
"go.problem.item3": "为尽可能多的程序员打造",
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7",
"go.problem.item4": "包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5",
"go.how.title": "Go 如何工作",
"go.how.body": "Go 起价为首月 $5之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "创建账户",
@@ -307,10 +306,10 @@ export const dict = {
"go.faq.q1": "什么是 OpenCode Go",
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
"go.faq.q2": "Go 包含哪些模型?",
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5,并提供充裕的限额和可靠的访问。",
"go.faq.q3": "Go 和 Zen 一样吗?",
"go.faq.a3":
"不。Zen 是按量付费,而 Go 首月 $5之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
"不。Zen 是按量付费,而 Go 首月 $5之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5 和 MiniMax M2.5 等开源模型。",
"go.faq.q4": "Go 多少钱?",
"go.faq.a4.p1.beforePricing": "Go 费用为",
"go.faq.a4.p1.pricingLink": "首月 $5",
@@ -332,7 +331,7 @@ export const dict = {
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
"go.faq.a9":
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7并在滚动窗口5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60实际请求计数因模型和使用情况而异。",
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5并在滚动窗口5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60实际请求计数因模型和使用情况而异。",
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",

View File

@@ -240,8 +240,7 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情況",
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.meta.description":
"Go 首月 $5之後 $10/月,提供對 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
"go.meta.description": "Go 首月 $5之後 $10/月,提供對 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小時充裕請求額度。",
"go.hero.title": "低成本全民編碼模型",
"go.hero.body":
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
@@ -288,7 +287,7 @@ export const dict = {
"go.problem.item1": "低成本訂閱定價",
"go.problem.item2": "寬裕的限額與穩定存取",
"go.problem.item3": "專為盡可能多的程式設計師打造",
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7",
"go.problem.item4": "包含 GLM-5、Kimi K2.5 與 MiniMax M2.5",
"go.how.title": "Go 如何運作",
"go.how.body": "Go 起價為首月 $5之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
"go.how.step1.title": "建立帳號",
@@ -307,10 +306,10 @@ export const dict = {
"go.faq.q1": "什麼是 OpenCode Go",
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
"go.faq.q2": "Go 包含哪些模型?",
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5 與 MiniMax M2.5,並提供寬裕的限額與穩定存取。",
"go.faq.q3": "Go 與 Zen 一樣嗎?",
"go.faq.a3":
"不。Zen 是按量付費,而 Go 首月 $5之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
"不。Zen 是按量付費,而 Go 首月 $5之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5 和 MiniMax M2.5 等開源模型。",
"go.faq.q4": "Go 費用是多少?",
"go.faq.a4.p1.beforePricing": "Go 費用為",
"go.faq.a4.p1.pricingLink": "首月 $5",
@@ -332,7 +331,7 @@ export const dict = {
"go.faq.q9": "免費模型與 Go 有什麼區別?",
"go.faq.a9":
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7並在滾動視窗5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60實際請求數因模型和使用情況而異。",
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5 與 MiniMax M2.5並在滾動視窗5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60實際請求數因模型和使用情況而異。",
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",

View File

@@ -47,8 +47,7 @@ function LimitsGraph(props: { href: string }) {
const models = [
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
{ id: "minimax", name: "MiniMax M2.5", req: 20000, d: "360ms" },
]
const w = 720

View File

@@ -279,7 +279,6 @@ export function LiteSection() {
<li>Kimi K2.5</li>
<li>GLM-5</li>
<li>MiniMax M2.5</li>
<li>MiniMax M2.7</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<button

View File

@@ -120,7 +120,6 @@ export async function handler(
zenData,
authInfo,
modelInfo,
ip,
sessionId,
trialProviders,
retry,
@@ -403,7 +402,6 @@ export async function handler(
zenData: ZenData,
authInfo: AuthInfo,
modelInfo: ModelInfo,
ip: string,
sessionId: string,
trialProviders: string[] | undefined,
retry: RetryOptions,
@@ -432,11 +430,10 @@ export async function handler(
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
// Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip
let h = 0
const l = identifier.length
const l = sessionId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + identifier.charCodeAt(i)) | 0 // 32-bit int
h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]

View File

@@ -30,7 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"effect": "catalog:",
"effect": "4.0.0-beta.31",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",

View File

@@ -9,55 +9,71 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
# opencode Effect rules
# opencode Effect guide
Use these rules when writing or migrating Effect code.
Instructions to follow when writing Effect.
See `specs/effect-migration.md` for the compact pattern reference and examples.
## Schemas
## Core
- Use `Schema.Class` for data types with multiple fields.
- Use branded schemas (`Schema.brand`) for single-value types.
## Services
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
## Errors
- Use `Schema.TaggedErrorClass` for typed errors.
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
## Effects
- Use `Effect.gen(function* () { ... })` for composition.
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
- Use `Effect.callback` for callback-based APIs.
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
## Time
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Schemas and errors
## Errors
- Use `Schema.Class` for multi-field data.
- Use branded schemas (`Schema.brand`) for single-value types.
- Use `Schema.TaggedErrorClass` for typed errors.
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
- In `Effect.gen` / `Effect.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.
## Runtime vs Instances
## Instance-scoped Effect services
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
- If two open directories should not share one copy of the service, it belongs in `Instances`.
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
## Preferred Effect services
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()))`.
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
### Instance.bind — ALS context for native callbacks
## Instance.bind — ALS for native callbacks
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and 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`.
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
**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.

View File

@@ -6,7 +6,6 @@
"license": "MIT",
"private": true,
"scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
@@ -43,12 +42,11 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
@@ -82,7 +80,7 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"gitlab-ai-provider": "5.2.0",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -97,7 +95,7 @@
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@effect/platform-node": "4.0.0-beta.31",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -110,7 +108,6 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",

View File

@@ -1,144 +0,0 @@
# Effect patterns
Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope
Use the shared runtime for process-wide services with one lifecycle for the whole app.
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
- Shared runtime: config readers, stateless helpers, global clients
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
## Service shape
For a fully migrated module, use the public namespace directly:
```ts
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
return Service.of({
get: Effect.fn("Foo.get")(function* (id) {
return yield* ...
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
}
```
Rules:
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
- Export `defaultLayer` only when wiring dependencies is useful
- Use the direct namespace form once the module is fully migrated
## Temporary mixed-mode pattern
Prefer a single namespace whenever possible.
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
```ts
export namespace FooEffect {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(...)
}
```
Then keep the old boundary thin:
```ts
export namespace Foo {
export function get(id: FooID) {
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
}
}
```
Remove the `Effect` suffix when the boundary split is gone.
## Scheduled Tasks
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
## Preferred Effect services
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
Prefer these first:
- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
- `HttpClient.HttpClient` instead of raw `fetch`
- `Path.Path` instead of mixing path helpers into service code when you already need a path service
- `Config` for effect-native configuration reads
- `Clock` / `DateTime` for time reads inside effects
## Child processes
For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
Keep shelling-out code inside the service, not in callers.
## Shared leaf models
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
## Migration checklist
Done now:
- [x] `AccountEffect` (mixed-mode)
- [x] `AuthEffect` (mixed-mode)
- [x] `TruncateEffect` (mixed-mode)
- [x] `Question`
- [x] `PermissionNext`
- [x] `ProviderAuth`
- [x] `FileWatcher`
- [x] `FileTime`
- [x] `Format`
- [x] `Vcs`
- [x] `Skill`
- [x] `Discovery`
- [x] `File`
- [x] `Snapshot`
Still open and likely worth migrating:
- [ ] `Plugin`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`
- [ ] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`
- [ ] `SessionCompaction`
- [ ] `Provider`
- [ ] `Project`
- [ ] `LSP`
- [ ] `MCP`

View File

@@ -5,20 +5,20 @@ import {
type AccountError,
type AccessToken,
AccountID,
AccountEffect,
AccountService,
OrgID,
} from "./effect"
} from "./service"
export { AccessToken, AccountID, OrgID } from "./effect"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountEffect.Service.use(f))
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
return runtime.runSync(AccountService.use(f))
}
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountEffect.Service.use(f))
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f))
}
export namespace Account {

View File

@@ -108,8 +108,8 @@ const mapAccountServiceError =
),
)
export namespace AccountEffect {
export interface Interface {
export namespace AccountService {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
@@ -124,11 +124,11 @@ export namespace AccountEffect {
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
@@ -148,6 +148,8 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"),
)
// Returns a usable access token for a stored account row, refreshing and
// persisting it when the cached token has expired.
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -216,11 +218,11 @@ export namespace AccountEffect {
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
@@ -235,7 +237,7 @@ export namespace AccountEffect {
return results
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
@@ -244,7 +246,7 @@ export namespace AccountEffect {
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
@@ -268,7 +270,7 @@ export namespace AccountEffect {
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
@@ -289,7 +291,7 @@ export namespace AccountEffect {
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
@@ -335,7 +337,7 @@ export namespace AccountEffect {
return new PollSuccess({ email: account.email })
})
return Service.of({
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
@@ -350,5 +352,8 @@ export namespace AccountEffect {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}

View File

@@ -1,12 +1,12 @@
import { Effect } from "effect"
import z from "zod"
import { runtime } from "@/effect/runtime"
import * as S from "./effect"
import * as S from "./service"
export { OAUTH_DUMMY_KEY } from "./effect"
export { OAUTH_DUMMY_KEY } from "./service"
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
return runtime.runPromise(S.AuthEffect.Service.use(f))
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
return runtime.runPromise(S.AuthService.use(f))
}
export namespace Auth {

View File

@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthEffect {
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
@@ -62,11 +62,11 @@ export namespace AuthEffect {
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
@@ -77,7 +77,7 @@ export namespace AuthEffect {
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
@@ -88,7 +88,14 @@ export namespace AuthEffect {
})
})
return Service.of({ get, all, set, remove })
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}

View File

@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountEffect.Service
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountEffect.Service
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
}
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountEffect.Service
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountEffect.Service
const service = yield* AccountService
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")

View File

@@ -51,7 +51,7 @@ export const ModelsCommand = cmd({
}
if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
const provider = providers[args.provider]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return

View File

@@ -112,15 +112,21 @@ export const PrCommand = cmd({
UI.println("Starting opencode...")
UI.println()
// Launch opencode TUI with session ID if available
const { spawn } = await import("child_process")
const opencodeArgs = sessionId ? ["-s", sessionId] : []
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
const opencodeProcess = spawn("opencode", opencodeArgs, {
stdio: "inherit",
cwd: process.cwd(),
})
const code = await opencodeProcess.exited
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
await new Promise<void>((resolve, reject) => {
opencodeProcess.on("exit", (code) => {
if (code === 0) resolve()
else reject(new Error(`opencode exited with code ${code}`))
})
opencodeProcess.on("error", reject)
})
},
})
},

View File

@@ -46,13 +46,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
if (prompt.condition && !prompt.condition(inputs)) {
continue
}
if (prompt.condition && !prompt.condition(inputs)) continue
if (prompt.type === "select") {
const value = await prompts.select({
message: prompt.message,

View File

@@ -8,7 +8,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
@@ -27,7 +27,6 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
@@ -70,29 +69,10 @@ export function createDialogProviderOptions() {
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
@@ -277,53 +257,3 @@ function ApiMethod(props: ApiMethodProps) {
/>
)
}
interface PromptsMethodProps {
dialog: ReturnType<typeof useDialog>
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
}
async function PromptsMethod(props: PromptsMethodProps) {
const inputs: Record<string, string> = {}
for (const prompt of props.prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((x) => ({
title: x.label,
value: x.value,
description: x.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}

View File

@@ -1,31 +1,31 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { File } from "@/file"
import { FileTime } from "@/file/time"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { PermissionNext } from "@/permission"
import { FileService } from "@/file"
import { FileTimeService } from "@/file/time"
import { FileWatcherService } from "@/file/watcher"
import { FormatService } from "@/format"
import { PermissionEffect } from "@/permission/service"
import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { VcsService } from "@/project/vcs"
import { ProviderAuthService } from "@/provider/auth-service"
import { QuestionService } from "@/question/service"
import { SkillService } from "@/skill/skill"
import { SnapshotService } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
| Question.Service
| PermissionNext.Service
| ProviderAuth.Service
| FileWatcher.Service
| Vcs.Service
| FileTime.Service
| Format.Service
| File.Service
| Skill.Service
| Snapshot.Service
| QuestionService
| PermissionEffect.Service
| ProviderAuthService
| FileWatcherService
| VcsService
| FileTimeService
| FormatService
| FileService
| SkillService
| SnapshotService
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
@@ -36,16 +36,16 @@ export type InstanceServices =
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
Layer.fresh(Question.layer),
Layer.fresh(PermissionNext.layer),
Layer.fresh(ProviderAuth.defaultLayer),
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
Layer.fresh(Vcs.layer),
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
Layer.fresh(Format.layer),
Layer.fresh(File.layer),
Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
Layer.fresh(QuestionService.layer),
Layer.fresh(PermissionEffect.layer),
Layer.fresh(ProviderAuthService.layer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer),
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
Layer.fresh(FormatService.layer),
Layer.fresh(FileService.layer),
Layer.fresh(SkillService.layer),
Layer.fresh(SnapshotService.layer),
).pipe(Layer.provide(ctx))
}
@@ -55,7 +55,9 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
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)

View File

@@ -1,6 +1,6 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountEffect } from "@/account/effect"
import { AuthEffect } from "@/auth/effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
AccountEffect.defaultLayer, //
AccountService.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.layer)),
).pipe(Layer.provideMerge(AuthService.defaultLayer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

View File

@@ -1,20 +1,272 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git"
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "file" })
const binaryExtensions = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const imageExtensions = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const textExtensions = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
function shouldEncode(mimeType: string): boolean {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export namespace File {
export const Info = z
@@ -84,270 +336,28 @@ export namespace File {
}
export function init() {
return runPromiseInstance(Service.use((svc) => svc.init()))
return runPromiseInstance(FileService.use((s) => s.init()))
}
export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status()))
return runPromiseInstance(FileService.use((s) => s.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file)))
return runPromiseInstance(FileService.use((s) => s.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
return runPromiseInstance(FileService.use((s) => s.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input)))
return runPromiseInstance(FileService.use((s) => s.search(input)))
}
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
export namespace FileService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
@@ -359,83 +369,89 @@ export namespace File {
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
static readonly layer = Layer.effect(
FileService,
Effect.gen(function* () {
const instance = yield* InstanceContext
// File cache state
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
let task: Promise<void> | undefined
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
const scan = Effect.fn("File.scan")(function* () {
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
function kick() {
if (task) return task
task = (async () => {
// Disable scanning if in root of file system
if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] }
try {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
yield* Effect.promise(async () => {
if (isGlobalHome) {
const dirs = new Set<string>()
const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (seen.has(dir)) continue
seen.add(dir)
next.dirs.push(dir + "/")
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
next.dirs = Array.from(dirs).toSorted()
} else {
const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (set.has(dir)) continue
set.add(dir)
next.dirs.push(dir + "/")
}
}
}
cache = next
} finally {
task = undefined
}
})
})()
return task
}
cache = next
const getFiles = async () => {
void kick()
return cache
}
const init = Effect.fn("FileService.init")(function* () {
yield* Effect.promise(() => kick())
})
const getFiles = () => cache
const scope = yield* Scope.Scope
let fiber: Fiber.Fiber<void> | undefined
const init = Effect.fn("File.init")(function* () {
if (!fiber) {
fiber = yield* scan().pipe(
Effect.catchCause(() => Effect.void),
Effect.forkIn(scope),
)
}
yield* Fiber.join(fiber)
})
const status = Effect.fn("File.status")(function* () {
const status = Effect.fn("FileService.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
@@ -445,13 +461,14 @@ export namespace File {
})
).text()
const changed: File.Info[] = []
const changedFiles: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
@@ -477,12 +494,14 @@ export namespace File {
).text()
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Filesystem.readText(path.join(instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
const content = await Filesystem.readText(path.join(instance.directory, filepath))
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
added: lines,
removed: 0,
status: "added",
})
@@ -492,6 +511,7 @@ export namespace File {
}
}
// Get deleted files
const deletedOutput = (
await git(
[
@@ -511,51 +531,50 @@ export namespace File {
).text()
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
path: filepath,
added: 0,
removed: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return changedFiles.map((x) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
return {
...item,
...x,
path: path.relative(instance.directory, full),
}
})
})
})
const read = Effect.fn("File.read")(function* (file: string) {
const read = Effect.fn("FileService.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file })
const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error("Access denied: path escapes project directory")
throw new Error(`Access denied: path escapes project directory`)
}
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
const content = buffer.toString("base64")
const mimeType = getImageMimeType(file)
return { type: "text", content, mimeType, encoding: "base64" }
}
return { type: "text", content: "" }
}
const knownText = isTextByExtension(file) || isTextByName(file)
const text = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !knownText) {
if (isBinaryByExtension(file) && !text) {
return { type: "binary", content: "" }
}
@@ -564,7 +583,7 @@ export namespace File {
}
const mimeType = Filesystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType)
const encode = text ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
@@ -572,12 +591,8 @@ export namespace File {
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
return {
type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
const content = buffer.toString("base64")
return { type: "text", content, mimeType, encoding: "base64" }
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
@@ -588,9 +603,7 @@ export namespace File {
).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
).text()
}
if (diff.trim()) {
@@ -599,64 +612,64 @@ export namespace File {
context: Infinity,
ignoreWhitespace: true,
})
return {
type: "text",
content,
patch,
diff: formatPatch(patch),
}
const diff = formatPatch(patch)
return { type: "text", content, patch, diff }
}
}
return { type: "text", content }
})
})
const list = Effect.fn("File.list")(function* (dir?: string) {
const list = Effect.fn("FileService.list")(function* (dir?: string) {
return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (instance.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignore))
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) {
ig.add(await Filesystem.readText(gitignorePath))
}
const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignoreFile))
const ignorePath = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) {
ig.add(await Filesystem.readText(ignorePath))
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
throw new Error("Access denied: path escapes project directory")
throw new Error(`Access denied: path escapes project directory`)
}
const nodes: File.Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
for (const entry of await fs.promises
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(instance.directory, absolute)
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: file,
absolute,
path: relativePath,
absolute: fullPath,
type,
ignored: ignored(type === "directory" ? file + "/" : file),
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
})
})
const search = Effect.fn("File.search")(function* (input: {
const search = Effect.fn("FileService.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
@@ -668,20 +681,35 @@ export namespace File {
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
const result = await getFiles()
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
return output
@@ -689,7 +717,8 @@ export namespace File {
})
log.info("init")
return Service.of({ init, status, read, list, search })
return FileService.of({ init, status, read, list, search })
}),
)
}

View File

@@ -1,110 +1,115 @@
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import type { SessionID } from "@/session/schema"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
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>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
export const layer = Layer.effect(
Service,
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>()
const getLock = (filepath: string) => {
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
function getLock(filepath: string) {
let lock = locks.get(filepath)
if (!lock) {
lock = Semaphore.makeUnsafe(1)
locks.set(filepath, lock)
}
return lock
}
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
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))
}),
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.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 = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
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.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
)
}
export namespace FileTime {
export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
}
export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
}
}

View File

@@ -1,83 +1,89 @@
import { Cause, Effect, Layer, ServiceMap } from "effect"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
import { Config } from "../config/config"
import path from "path"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises"
import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { git } from "@/util/git"
import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect"
const SUBSCRIBE_TIMEOUT_MS = 10_000
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 {
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const 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"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const rel = path.relative(dir, item)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
})
}
export const Event = event
/** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher()
}
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
const init = Effect.fn("FileWatcherService.init")(function* () {})
export const layer = Layer.effect(
Service,
export namespace FileWatcherService {
export interface Service {
readonly init: () => Effect.Effect<void>
}
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
"@opencode/FileWatcher",
) {
static readonly layer = Layer.effect(
FileWatcherService,
Effect.gen(function* () {
const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
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 Service.of({})
return FileWatcherService.of({ init })
}
const w = watcher()
if (!w) return Service.of({})
if (!w) return FileWatcherService.of({ init })
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
@@ -87,9 +93,9 @@ export namespace FileWatcher {
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" })
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" })
}
})
@@ -102,6 +108,7 @@ export namespace FileWatcher {
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
}),
@@ -112,7 +119,7 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()])
}
if (instance.project.vcs === "git") {
@@ -130,11 +137,11 @@ export namespace FileWatcher {
}
}
return Service.of({})
return FileWatcherService.of({ init })
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
return Effect.succeed(FileWatcherService.of({ init }))
}),
),
)

View File

@@ -1,197 +0,0 @@
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type Error = PlatformError | FileSystemError
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
})
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "File"
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
})
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
yield* fs.makeDirectory(path, { recursive: true })
})
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
path: string,
content: string | Uint8Array,
mode?: number,
) {
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
yield* write.pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
yield* fs.makeDirectory(dirname(path), { recursive: true })
yield* write
}),
),
)
if (mode) yield* fs.chmod(path, mode)
})
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
})
})
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
return Service.of({
...fs,
isDir,
isFile,
readJson,
writeJson,
ensureDir,
writeWithDirs,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
try {
return realpathSync.native(p)
} catch {
return p
}
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT") return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
}

View File

@@ -1,20 +1,21 @@
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "format" })
export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z
.object({
name: z.string(),
@@ -26,14 +27,25 @@ export namespace Format {
})
export type Status = z.infer<typeof Status>
export interface Interface {
readonly status: () => Effect.Effect<Status[]>
export async function init() {
return runPromiseInstance(FormatService.use((s) => s.init()))
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export async function status() {
return runPromiseInstance(FormatService.use((s) => s.status()))
}
}
export const layer = Layer.effect(
Service,
export namespace FormatService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () {
const instance = yield* InstanceContext
@@ -51,19 +63,17 @@ export namespace Format {
delete formatters[name]
continue
}
const info = mergeDeep(formatters[name] ?? {}, {
const result = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
}) as Formatter.Info
if (info.command.length === 0) continue
if (result.command.length === 0) continue
formatters[name] = {
...info,
name,
enabled: async () => true,
}
result.enabled = async () => true
result.name = name
formatters[name] = result
}
} else {
log.info("all formatters are disabled")
@@ -90,52 +100,50 @@ export namespace Format {
return result
}
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
File.Event.Edited,
Instance.bind(async (payload) => {
const file = payload.properties.file
log.info("formatting", { file })
const ext = path.extname(file)
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,
})
}
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
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 status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
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({
@@ -147,11 +155,7 @@ export namespace Format {
return result
})
return Service.of({ status })
return FormatService.of({ init, status })
}),
)
export async function status() {
return runPromiseInstance(Service.use((s) => s.status()))
}
}

View File

@@ -1,9 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { spawn } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { Process } from "@/util/process"
const SUPPORTED_IDES = [
{ name: "Windsurf" as const, cmd: "windsurf" },
@@ -52,11 +52,13 @@ export namespace Ide {
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
nothrow: true,
const p = spawn([cmd, "--install-extension", "sst-dev.opencode"], {
stdout: "pipe",
stderr: "pipe",
})
const stdout = p.stdout.toString()
const stderr = p.stderr.toString()
await p.exited
const stdout = await new Response(p.stdout).text()
const stderr = await new Response(p.stderr).text()
log.info("installed", {
ide,
@@ -64,7 +66,7 @@ export namespace Ide {
stderr,
})
if (p.code !== 0) {
if (p.exitCode !== 0) {
throw new InstallFailedError({ stderr })
}
if (stdout.includes("already installed")) {

View File

@@ -5,7 +5,6 @@ import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import z from "zod"
import type { LSPServer } from "./server"
@@ -240,7 +239,7 @@ export namespace LSPClient {
l.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
input.server.process.kill()
l.info("shutdown")
},
}

View File

@@ -7,10 +7,9 @@ import { pathToFileURL, fileURLToPath } from "url"
import { LSPServer } from "./server"
import z from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -113,8 +112,9 @@ export namespace LSP {
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => {
return {
process: lspspawn(item.command[0], item.command.slice(1), {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
windowsHide: true,
env: {
...process.env,
...item.env,
@@ -200,20 +200,21 @@ export namespace LSP {
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
}).catch((err) => {
s.broken.add(key)
await Process.stop(handle.process)
handle.process.kill()
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) {
handle.process.kill()
return undefined
}
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
handle.process.kill()
return existing
}

View File

@@ -1,21 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "child_process"
import { Process } from "../util/process"
type Child = Process.Child & ChildProcessWithoutNullStreams
export function spawn(cmd: string, args: string[], opts?: Process.Options): Child
export function spawn(cmd: string, opts?: Process.Options): Child
export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) {
const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : []
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
const proc = Process.spawn([cmd, ...args], {
...(cfg ?? {}),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}) as Child
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
return proc
}

View File

@@ -1,4 +1,4 @@
import type { ChildProcessWithoutNullStreams } from "child_process"
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
import path from "path"
import os from "os"
import { Global } from "../global"
@@ -13,7 +13,11 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { spawn } from "./launch"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
return launch(cmd, { ...(args ?? {}), windowsHide: true })
}) as typeof launch
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -269,7 +273,7 @@ export namespace LSPServer {
}
if (lintBin) {
const proc = spawn(lintBin, ["--help"])
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
await proc.exited
if (proc.stdout) {
const help = await text(proc.stdout)

View File

@@ -1,251 +1,11 @@
import { runPromiseInstance } from "@/effect/runtime"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
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 { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { PermissionID } from "./schema"
import { PermissionEffect as S } from "./service"
export namespace PermissionNext {
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 Error = DeniedError | 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 interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
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("Permission.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("Permission.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)
}
})
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
)
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
@@ -254,11 +14,32 @@ export namespace PermissionNext {
return pattern
}
export const Action = S.Action
export type Action = S.Action
export const Rule = S.Rule
export type Rule = S.Rule
export const Ruleset = S.Ruleset
export type Ruleset = S.Ruleset
export const Request = S.Request
export type Request = S.Request
export const Reply = S.Reply
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
export const Service = S.Service
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
ruleset.push({
permission: key,
action: value,
pattern: "*",
})
continue
}
ruleset.push(
@@ -272,12 +53,18 @@ export namespace PermissionNext {
return rulesets.flat()
}
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.Service.use((service) => service.reply(input))),
)
export async function list() {
return runPromiseInstance(Service.use((svc) => svc.list()))
return runPromiseInstance(S.Service.use((service) => service.list()))
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return S.evaluate(permission, pattern, ...rulesets)
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]

View File

@@ -0,0 +1,244 @@
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"
export namespace PermissionEffect {
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 Error = DeniedError | 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 interface Api {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
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)
}
})
const list = Effect.fn("PermissionService.list")(function* () {
return Array.from(pending.values(), (item) => item.info)
})
return Service.of({ ask, reply, list })
}),
)
}

View File

@@ -1,7 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Log } from "../util/log"
import { Installation } from "../installation"
import { OAUTH_DUMMY_KEY } from "../auth"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -360,14 +360,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set([
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.1-codex",
])
for (const modelId of Object.keys(provider.models)) {
if (modelId.includes("codex")) continue

View File

@@ -168,7 +168,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
key: "enterpriseUrl",
message: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
when: { key: "deploymentType", op: "eq", value: "enterprise" },
condition: (inputs) => inputs.deploymentType === "enterprise",
validate: (value) => {
if (!value) return "URL or domain is required"
try {

View File

@@ -1,86 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { discoverWorkflowModels } from "gitlab-ai-provider"
import { Log } from "@/util/log"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
const log = Log.create({ service: "plugin.gitlab" })
function str(value: unknown, fallback: string) {
if (typeof value === "string" && value) return value
return fallback
}
export async function GitlabPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "gitlab",
models: {
async reconcile(args) {
const url = str(args.options?.instanceUrl, "https://gitlab.com")
const token = str(args.options?.apiKey, "")
if (!token) return
const headers = (): Record<string, string> => {
if (args.auth?.type === "api") return { "PRIVATE-TOKEN": token }
return { Authorization: `Bearer ${token}` }
}
try {
log.info("gitlab model discovery starting", { instanceUrl: url })
const res = await discoverWorkflowModels(
{ instanceUrl: url, getHeaders: headers },
{ workingDirectory: input.directory },
)
if (!res.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: res.project ? { id: res.project.id, path: res.project.pathWithNamespace } : null,
})
return
}
for (const model of res.models) {
if (args.models[model.id]) {
continue
}
const m = {
id: ModelID.make(model.id),
providerID: ProviderID.make("gitlab"),
name: `Agent Platform (${model.name})`,
api: {
id: model.id,
url,
npm: "gitlab-ai-provider",
},
status: "active" as const,
headers: {},
options: { workflowRef: model.ref },
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: model.context, output: model.output },
capabilities: {
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: "",
variants: {} as Record<string, Record<string, any>>,
}
m.variants = ProviderTransform.variants(m)
args.models[model.id] = m
}
return args.models
} catch (err) {
log.warn("gitlab model discovery failed", { error: err })
return
}
},
},
},
}
}

View File

@@ -12,7 +12,6 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { GitlabPlugin } from "./gitlab"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -20,7 +19,7 @@ export namespace Plugin {
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, GitlabPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
const state = Instance.state(async () => {
const client = createOpencodeClient({
@@ -111,9 +110,9 @@ export namespace Plugin {
})
export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "provider">,
Input = Parameters<Extract<Required<Hooks>[Name], (...args: any) => any>>[0],
Output = Parameters<Extract<Required<Hooks>[Name], (...args: any) => any>>[1],
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
if (!name) return output
for (const hook of await state().then((x) => x.hooks)) {

View File

@@ -1,23 +1,30 @@
import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher"
import { File } from "../file"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
await Format.init()
await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(Instance.project.id)
await Project.setInitialized(Instance.project.id)
}
})
}

View File

@@ -1,15 +1,282 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git"
import { Instance } from "./instance"
import z from "zod"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { Effect, Layer, ServiceMap } from "effect"
import path from "path"
const log = Log.create({ service: "vcs" })
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
type Base = { name: string; ref: string }
type Item = { file: string; code: string; status: Snapshot.FileDiff["status"] }
async function mapLimit<T, R>(list: T[], limit: number, fn: (item: T) => Promise<R>) {
const size = Math.max(1, limit)
const out: R[] = new Array(list.length)
let idx = 0
await Promise.all(
Array.from({ length: Math.min(size, list.length) }, async () => {
while (true) {
const i = idx
idx += 1
if (i >= list.length) return
out[i] = await fn(list[i]!)
}
}),
)
return out
}
function out(result: { text(): string }) {
return result.text().trim()
}
async function run(cwd: string, args: string[]) {
return git([...cfg, ...args], { cwd })
}
async function branch(cwd: string) {
const result = await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
if (result.exitCode !== 0) return
const text = out(result)
return text || undefined
}
async function prefix(cwd: string) {
const result = await run(cwd, ["rev-parse", "--show-prefix"])
if (result.exitCode !== 0) return ""
return out(result)
}
async function branches(cwd: string) {
const result = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
if (result.exitCode !== 0) return []
return out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
}
async function configured(cwd: string, list: string[]) {
const result = await run(cwd, ["config", "init.defaultBranch"])
if (result.exitCode !== 0) return
const name = out(result)
if (!name || !list.includes(name)) return
const ref = await run(cwd, ["rev-parse", "--verify", name])
if (ref.exitCode !== 0) return
return { name, ref: name } satisfies Base
}
async function remoteHead(cwd: string, remote: string) {
const result = await run(cwd, ["ls-remote", "--symref", remote, "HEAD"])
if (result.exitCode !== 0) return
for (const line of result.text().split("\n")) {
const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim())
if (!match?.[1]) continue
return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base
}
}
async function primary(cwd: string) {
const result = await run(cwd, ["remote"])
const list =
result.exitCode !== 0
? []
: out(result)
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
if (list.includes("origin")) return "origin"
if (list.length === 1) return list[0]
if (list.includes("upstream")) return "upstream"
return list[0]
}
async function base(cwd: string) {
const remote = await primary(cwd)
if (remote) {
const head = await run(cwd, ["symbolic-ref", `refs/remotes/${remote}/HEAD`])
if (head.exitCode === 0) {
const ref = out(head).replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
}
const next = await remoteHead(cwd, remote)
if (next) return next
}
const list = await branches(cwd)
const next = await configured(cwd, list)
if (next) return next
for (const name of ["main", "master"]) {
if (list.includes(name)) return { name, ref: name }
}
}
async function head(cwd: string) {
const result = await run(cwd, ["rev-parse", "--verify", "HEAD"])
return result.exitCode === 0
}
async function work(cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(await Filesystem.exists(full))) return ""
const buf = await Filesystem.readBytes(full).catch(() => Buffer.alloc(0))
if (buf.includes(0)) return ""
return buf.toString("utf8")
}
async function show(cwd: string, ref: string | undefined, file: string, base: string) {
if (!ref) return ""
const target = base ? `${base}${file}` : file
const result = await run(cwd, ["show", `${ref}:${target}`])
if (result.exitCode !== 0) return ""
return result.text()
}
function kind(code: string | undefined): "added" | "deleted" | "modified" {
if (code === "??") return "added"
if (code?.includes("U")) return "modified"
if (code?.includes("A") && !code.includes("D")) return "added"
if (code?.includes("D") && !code.includes("A")) return "deleted"
return "modified"
}
function count(text: string) {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
function split(text: string) {
return text.split("\0").filter(Boolean)
}
function parseStatus(text: string) {
return split(text).flatMap((item) => {
const file = item.slice(3)
if (!file) return []
const code = item.slice(0, 2)
return [{ file, code, status: kind(code) } satisfies Item]
})
}
function parseNames(text: string) {
const list = split(text)
const out: Item[] = []
for (let i = 0; i < list.length; i += 2) {
const code = list[i]
const file = list[i + 1]
if (!code || !file) continue
out.push({ file, code, status: kind(code) })
}
return out
}
function parseNums(text: string) {
const out = new Map<string, { additions: number; deletions: number }>()
for (const item of split(text)) {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
if (a === -1 || b === -1) continue
const file = item.slice(b + 1)
if (!file) continue
const adds = item.slice(0, a)
const dels = item.slice(a + 1, b)
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
out.set(file, {
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
})
}
return out
}
function merge(...lists: Item[][]) {
const out = new Map<string, Item>()
for (const list of lists) {
for (const item of list) {
if (!out.has(item.file)) out.set(item.file, item)
}
}
return [...out.values()]
}
async function files(cwd: string, ref: string | undefined, list: Item[], nums: Map<string, { additions: number; deletions: number }>) {
const base = ref ? await prefix(cwd) : ""
const next = await mapLimit(list, 8, async (item) => {
const before = item.status === "added" ? "" : await show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : await work(cwd, item.file)
const stat = nums.get(item.file)
return {
file: item.file,
before,
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
})
return next.toSorted((a, b) => a.file.localeCompare(b.file))
}
async function status(cwd: string) {
const result = await run(cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."])
return parseStatus(result.text())
}
async function stats(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."])
return parseNums(result.text())
}
async function diff(cwd: string, ref: string) {
const result = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."])
return parseNames(result.text())
}
async function track(cwd: string, ref: string | undefined) {
const [list, nums] = ref ? await Promise.all([status(cwd), stats(cwd, ref)]) : [await status(cwd), new Map()]
return files(cwd, ref, list, nums)
}
async function compare(cwd: string, ref: string) {
const [list, nums, extra] = await Promise.all([diff(cwd, ref), stats(cwd, ref), status(cwd)])
return files(
cwd,
ref,
merge(
list,
extra.filter((item) => item.code === "??"),
),
nums,
)
}
export namespace Vcs {
const log = Log.create({ service: "vcs" })
export const Mode = z.enum(["git", "branch"])
export type Mode = z.infer<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
@@ -22,60 +289,78 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string(),
branch: z.string().optional(),
default_branch: z.string().optional(),
})
.meta({
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
}
export interface Interface {
export namespace VcsService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Vcs.Mode) => Effect.Effect<Snapshot.FileDiff[]>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect(
Service,
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 currentBranch: string | undefined
let current: string | undefined
let root: Base | undefined
if (instance.project.vcs === "git") {
const getCurrentBranch = 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
const currentBranch = async () => {
return branch(instance.directory)
}
currentBranch = yield* Effect.promise(() => getCurrentBranch())
log.info("initialized", { branch: currentBranch })
;[current, root] = yield* Effect.promise(() => Promise.all([currentBranch(), base(instance.directory)]))
log.info("initialized", { branch: current, default_branch: root?.name })
yield* Effect.acquireRelease(
Effect.sync(() =>
Bus.subscribe(
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
const next = await getCurrentBranch()
if (next !== currentBranch) {
log.info("branch changed", { from: currentBranch, to: next })
currentBranch = next
Bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
),
(unsubscribe) => Effect.sync(unsubscribe),
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 Service.of({
branch: Effect.fn("Vcs.branch")(function* () {
return currentBranch
return VcsService.of({
init: Effect.fn("VcsService.init")(function* () {}),
branch: Effect.fn("VcsService.branch")(function* () {
return current
}),
defaultBranch: Effect.fn("VcsService.defaultBranch")(function* () {
return root?.name
}),
diff: Effect.fn("VcsService.diff")(function* (mode: Vcs.Mode) {
if (instance.project.vcs !== "git") return []
if (mode === "git") {
const ok = yield* Effect.promise(() => head(instance.directory))
return yield* Effect.promise(() => track(instance.directory, ok ? "HEAD" : undefined))
}
if (!root) return []
if (current && current === root.name) return []
const ref = yield* Effect.promise(() => run(instance.directory, ["merge-base", root.ref, "HEAD"]))
if (ref.exitCode !== 0) return []
const text = out(ref)
if (!text) return []
return yield* Effect.promise(() => compare(instance.directory, text))
}),
})
}),

View File

@@ -0,0 +1,143 @@
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service"
import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod"
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create(
"ProviderAuthOauthMissing",
z.object({
providerID: ProviderID.zod,
}),
)
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({
providerID: ProviderID.zod,
}),
)
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export type ProviderAuthError =
| Auth.AuthServiceError
| InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
export namespace ProviderAuthService {
export interface Service {
readonly methods: () => Effect.Effect<Record<string, Method[]>>
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
readonly callback: (input: {
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
}
}
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
"@opencode/ProviderAuth",
) {
static readonly layer = Layer.effect(
ProviderAuthService,
Effect.gen(function* () {
const auth = yield* Auth.AuthService
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
return pipe(
await mod.Plugin.list(),
filter((x) => x.auth?.provider !== undefined),
map((x) => [x.auth!.provider, x.auth!] as const),
fromEntries(),
)
})
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
})
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
providerID: ProviderID
method: number
}) {
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
const result = yield* Effect.promise(() => method.authorize())
pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
})
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
providerID: ProviderID
method: number
code?: string
}) {
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code)
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
yield* auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
return ProviderAuthService.of({
methods,
authorize,
callback,
})
}),
)
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
}

View File

@@ -1,231 +1,28 @@
import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/effect"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
import z from "zod"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn"
import * as S from "./auth-service"
import { ProviderID } from "./schema"
export namespace ProviderAuth {
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: z
.array(
z.union([
z.object({
type: z.literal("text"),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
z.object({
type: z.literal("select"),
key: z.string(),
message: z.string(),
options: z.array(
z.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
}),
),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
]),
)
.optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing",
z.object({ providerID: ProviderID.zod }),
)
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const ValidationFailed = NamedError.create(
"ProviderAuthValidationFailed",
z.object({
field: z.string(),
message: z.string(),
}),
)
export type Error =
| Auth.AuthError
| InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed>
| InstanceType<typeof ValidationFailed>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly authorize: (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service
const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin")
const plugins = await mod.Plugin.list()
return Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
)
})
const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuth.methods")(function* () {
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
type: method.type,
label: method.label,
prompts: method.prompts?.map((prompt) => {
if (prompt.type === "select") {
return {
type: "select" as const,
key: prompt.key,
message: prompt.message,
options: prompt.options,
when: prompt.when,
}
}
return {
type: "text" as const,
key: prompt.key,
message: prompt.message,
placeholder: prompt.placeholder,
when: prompt.when,
}
}),
}),
),
)
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}) {
const method = hooks[input.providerID].methods[input.method]
if (method.type !== "oauth") return
if (method.prompts && input.inputs) {
for (const prompt of method.prompts) {
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
const error = prompt.validate(input.inputs[prompt.key])
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
}
}
}
const result = yield* Effect.promise(() => method.authorize(input.inputs))
pending.set(input.providerID, result)
return {
url: result.url,
method: result.method,
instructions: result.instructions,
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID
method: number
code?: string
}) {
const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(),
)
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
if ("key" in result) {
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
yield* auth.set(input.providerID, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
...(result.accountId ? { accountId: result.accountId } : {}),
})
}
})
return Service.of({ methods, authorize, callback })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
export const Method = S.Method
export type Method = S.Method
export async function methods() {
return runPromiseInstance(Service.use((svc) => svc.methods()))
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
}
export const Authorization = S.Authorization
export type Authorization = S.Authorization
export const authorize = fn(
z.object({
providerID: ProviderID.zod,
method: z.number(),
inputs: z.record(z.string(), z.string()).optional(),
}),
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
)
export const callback = fn(
@@ -234,6 +31,10 @@ export namespace ProviderAuth {
method: z.number(),
code: z.string().optional(),
}),
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
)
export import OauthMissing = S.OauthMissing
export import OauthCodeMissing = S.OauthCodeMissing
export import OauthCallbackFailed = S.OauthCallbackFailed
}

View File

@@ -839,7 +839,7 @@ export namespace Provider {
return true
}
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const providers: { [providerID: string]: Info } = {}
const languages = new Map<string, LanguageModelV2>()
const modelLoaders: {
[providerID: string]: CustomModelLoader

View File

@@ -22,7 +22,6 @@ export const ProviderID = providerIdSchema.pipe(
azure: schema.makeUnsafe("azure"),
openrouter: schema.makeUnsafe("openrouter"),
mistral: schema.makeUnsafe("mistral"),
gitlab: schema.makeUnsafe("gitlab"),
})),
)

View File

@@ -1,193 +1,39 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
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" })
import * as S from "./service"
import type { QuestionID } from "./schema"
import type { SessionID, MessageID } from "@/session/schema"
export namespace Question {
// Schemas
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"
}
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
// Service
export interface Interface {
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 Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("Question.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("Question.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("Question.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("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
)
export const Option = S.Option
export type Option = S.Option
export const Info = S.Info
export type Info = S.Info
export const Request = S.Request
export type Request = S.Request
export const Answer = S.Answer
export type Answer = S.Answer
export const Reply = S.Reply
export type Reply = S.Reply
export const Event = S.Event
export const RejectedError = S.RejectedError
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
}
export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
}
export async function list(): Promise<Request[]> {
return runPromiseInstance(Service.use((svc) => svc.list()))
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
}
}

View File

@@ -0,0 +1,172 @@
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 })
}),
)
}

View File

@@ -109,16 +109,14 @@ export const ProviderRoutes = lazy(() =>
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
inputs: z.record(z.string(), z.string()).optional().meta({ description: "Prompt inputs" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const { method } = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID,
method,
inputs,
})
return c.json(result)
},

View File

@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Vcs, VcsService } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill"
@@ -41,6 +41,7 @@ import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Filesystem } from "@/util/filesystem"
import { Snapshot } from "@/snapshot"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -66,7 +67,6 @@ export namespace Server {
let status: ContentfulStatusCode
if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name === "ProviderAuthValidationFailed") status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
return c.json(err.toObject(), { status })
@@ -332,10 +332,39 @@ export namespace Server {
},
}),
async (c) => {
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({
branch,
})
const [branch, default_branch] = await Promise.all([
runPromiseInstance(VcsService.use((s) => s.branch())),
runPromiseInstance(VcsService.use((s) => s.defaultBranch())),
])
return c.json({ branch, default_branch })
},
)
.get(
"/vcs/diff",
describeRoute({
summary: "Get VCS diff",
description: "Retrieve the current git diff for the working tree or against the default branch.",
operationId: "vcs.diff",
responses: {
200: {
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"query",
z.object({
mode: Vcs.Mode,
}),
),
async (c) => {
const mode = c.req.valid("query").mode
return c.json(await runPromiseInstance(VcsService.use((s) => s.diff(mode))))
},
)
.get(

View File

@@ -956,7 +956,7 @@ export namespace MessageV2 {
{ cause: e },
).toObject()
case e instanceof Error:
return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
default:
try {
const parsed = ProviderError.parseStreamError(e)

View File

@@ -210,7 +210,7 @@ export namespace SessionProcessor {
state: {
status: "error",
input: value.input ?? match.state.input,
error: value.error instanceof Error ? value.error.message : String(value.error),
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),

View File

@@ -1,8 +1,10 @@
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
@@ -93,7 +95,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
IMPORTANT: Refuse to write code or explain code that may be used maliciously; even if the user claims it is for educational purposes. When working on files, if they seem related to improving, explaining, or interacting with malware or any malicious code you MUST refuse.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure. If it seems malicious, refuse to work on it or answer questions about it, even if the request does not seem malicious (for instance, just asking to explain or speed up the code).
# Code References
@@ -103,3 +106,4 @@ When referencing specific functions or pieces of code include the pattern `file_
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>

View File

@@ -3,7 +3,7 @@ import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_DEFAULT from "./prompt/default.txt"
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
@@ -26,7 +26,7 @@ export namespace SystemPrompt {
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
return [PROMPT_DEFAULT]
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}
export async function environment(model: Provider.Model) {

View File

@@ -1,116 +1,118 @@
import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AppFileSystem } from "@/filesystem"
import { Global } from "../global"
import { Log } from "../util/log"
import { withTransientReadRetry } from "@/util/effect-http-client"
export namespace Discovery {
const skillConcurrency = 4
const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
}) {}
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({
skills: Schema.Array(IndexSkill),
}) {}
class Index extends Schema.Class<Index>("Index")({
skills: Schema.Array(IndexSkill),
}) {}
const skillConcurrency = 4
const fileConcurrency = 8
export interface Interface {
export namespace DiscoveryService {
export interface Service {
readonly pull: (url: string) => Effect.Effect<string[]>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
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")
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* AppFileSystem.Service
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
const download = Effect.fn("Discovery.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.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to download", { url, err })
return false
}),
),
)
})
const pull = Effect.fn("Discovery.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 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
}
return true
})
}),
),
)
})
const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
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)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
log.info("fetching index", { url: index })
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
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
}),
),
)
return dirs.filter((dir): dir is string => dir !== 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
})
return Service.of({ pull })
}),
)
const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
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(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
}

View File

@@ -1,30 +1,34 @@
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { PermissionNext } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import path from "path"
import os from "os"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
const log = Log.create({ service: "skill" })
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export namespace Skill {
const log = Log.create({ service: "skill" })
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 Info = z.object({
name: z.string(),
description: z.string(),
@@ -51,205 +55,213 @@ export namespace Skill {
}),
)
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
const add = async (state: State, 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 = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
if (state.skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
})
}
// TODO: Migrate to Effect
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
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 scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills)
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
)
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
Layer.provide(Discovery.defaultLayer),
)
export async function get(name: string) {
return runPromiseInstance(Service.use((skill) => skill.get(name)))
return runPromiseInstance(SkillService.use((s) => s.get(name)))
}
export async function all() {
return runPromiseInstance(Service.use((skill) => skill.all()))
return runPromiseInstance(SkillService.use((s) => s.all()))
}
export async function dirs() {
return runPromiseInstance(Service.use((skill) => skill.dirs()))
return runPromiseInstance(SkillService.use((s) => s.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (list.length === 0) {
return "No skills are currently available."
}
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
` </skill>`,
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((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))
}

View File

@@ -1,15 +1,28 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
const log = Log.create({ service: "snapshot" })
const PRUNE = "7.days"
// Common git config flags shared across snapshot operations
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export namespace Snapshot {
export const Patch = z.object({
hash: z.string(),
@@ -31,47 +44,43 @@ export namespace Snapshot {
})
export type FileDiff = z.infer<typeof FileDiff>
// Promise facade — existing callers use these
export function init() {
void runPromiseInstance(SnapshotService.use((s) => s.init()))
}
export async function cleanup() {
return runPromiseInstance(Service.use((svc) => svc.cleanup()))
return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
}
export async function track() {
return runPromiseInstance(Service.use((svc) => svc.track()))
return runPromiseInstance(SnapshotService.use((s) => s.track()))
}
export async function patch(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
}
export async function restore(snapshot: string) {
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
}
}
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
export namespace SnapshotService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
@@ -80,91 +89,99 @@ export namespace Snapshot {
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<
Service,
never,
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
"@opencode/Snapshot",
) {
static readonly layer = Layer.effect(
SnapshotService,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* AppFileSystem.Service
const fileSystem = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
const project = ctx.project
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
const { directory, worktree, project } = ctx
const isGit = project.vcs === "git"
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
// Run git with nothrow semantics — always returns a result, never fails
const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
Effect.gen(function* () {
const command = ChildProcess.make("git", args, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const handle = yield* spawner.spawn(command)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
return { code, text, stderr }
}).pipe(
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
// Snapshot-specific error handling on top of AppFileSystem
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
// FileSystem helpers — orDie converts PlatformError to defects
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
// --- internal Effect helpers ---
const isEnabled = Effect.gen(function* () {
if (!isGit) return false
const cfg = yield* Effect.promise(() => Config.get())
return cfg.snapshot !== false
})
const excludes = Effect.fnUntraced(function* () {
const excludesPath = Effect.gen(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
if (!file) return undefined
if (!(yield* exists(file))) return undefined
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
const syncExclude = Effect.gen(function* () {
const file = yield* excludesPath
const target = path.join(snapshotGit, "info", "exclude")
yield* mkdir(path.join(snapshotGit, "info"))
if (!file) {
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
yield* writeFile(target, "")
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
const text = yield* readFile(file)
yield* writeFile(target, text)
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
const add = Effect.gen(function* () {
yield* syncExclude
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
})
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
if (!(yield* enabled())) return
if (!(yield* exists(gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
// --- service methods ---
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
if (!(yield* isEnabled)) return
if (!(yield* exists(snapshotGit))) return
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
cwd: directory,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
@@ -172,55 +189,58 @@ export namespace Snapshot {
})
return
}
log.info("cleanup", { prune })
log.info("cleanup", { prune: PRUNE })
})
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
const track = Effect.fn("SnapshotService.track")(function* () {
if (!(yield* isEnabled)) return undefined
const existed = yield* exists(snapshotGit)
yield* mkdir(snapshotGit)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
})
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: directory })
yield* add
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: gitdir })
log.info("tracking", { hash, cwd: directory, git: snapshotGit })
return hash
})
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
cwd: directory,
})
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
yield* add
const result = yield* git(
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
{ cwd: directory },
)
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
return { hash, files: [] } as Snapshot.Patch
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.map((x: string) => x.trim())
.filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
}
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
} as Snapshot.Patch
})
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
@@ -236,33 +256,38 @@ export namespace Snapshot {
})
})
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
const seen = new Set<string>()
for (const item of patches) {
for (const file of item.files) {
if (seen.has(file)) continue
seen.add(file)
log.info("reverting", { file, hash: item.hash })
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
cwd: worktree,
})
if (result.code !== 0) {
const rel = path.relative(worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
if (tree.code === 0 && tree.text.trim()) {
const relativePath = path.relative(worktree, file)
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
cwd: worktree,
})
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
yield* removeFile(file)
}
}
seen.add(file)
}
}
})
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
yield* add
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
@@ -271,15 +296,19 @@ export namespace Snapshot {
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
[
...GIT_CFG_QUOTE,
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{ cwd: directory },
)
@@ -287,63 +316,66 @@ export namespace Snapshot {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: directory,
},
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{ cwd: directory },
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const [before, after] = isBinaryFile
? ["", ""]
: yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
const added = isBinaryFile ? 0 : parseInt(additions!)
const deleted = isBinaryFile ? 0 : parseInt(deletions!)
result.push({
file,
file: file!,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
additions: Number.isFinite(added) ? added : 0,
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file!) ?? "modified",
})
}
return result
})
// Start hourly cleanup fiber — scoped to instance lifetime
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
return SnapshotService.of({
init: Effect.fn("SnapshotService.init")(function* () {}),
cleanup,
track,
patch,
restore,
revert,
diff,
diffFull,
})
}),
)
export const defaultLayer = layer.pipe(
).pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
}

View File

@@ -1,9 +1,8 @@
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { PermissionEffect } from "../permission/service"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
@@ -28,10 +27,10 @@ export namespace TruncateEffect {
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
export interface Api {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
@@ -40,14 +39,14 @@ export namespace TruncateEffect {
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
@@ -59,7 +58,11 @@ export namespace TruncateEffect {
}
})
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
const output = Effect.fn("TruncateEffect.output")(function* (
text: string,
options: Options = {},
agent?: Agent.Info,
) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
@@ -102,7 +105,7 @@ export namespace TruncateEffect {
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
@@ -133,5 +136,5 @@ export namespace TruncateEffect {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}

View File

@@ -0,0 +1,20 @@
import { Log } from "./log"
export namespace EventLoop {
export async function wait() {
return new Promise<void>((resolve) => {
const check = () => {
const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()]
Log.Default.info("eventloop", {
active,
})
if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) {
resolve()
} else {
setImmediate(check)
}
}
check()
})
}
}

View File

@@ -1,5 +1,4 @@
import { type ChildProcess } from "child_process"
import launch from "cross-spawn"
import { spawn as launch, type ChildProcess } from "child_process"
import { buffer } from "node:stream/consumers"
export namespace Process {
@@ -98,7 +97,6 @@ export namespace Process {
reject(error)
})
})
void exited.catch(() => undefined)
if (opts.abort) {
opts.abort.addEventListener("abort", abort, { once: true })
@@ -115,7 +113,6 @@ export namespace Process {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
shell: opts.shell,
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
@@ -143,20 +140,6 @@ export namespace Process {
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
export async function stop(proc: ChildProcess) {
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {

View File

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { AccountEffect } from "../../src/account/effect"
import { AccountService } from "../../src/account/service"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) =>
AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
@@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
}),
)
const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () =>
),
)
const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -158,9 +158,7 @@ it.effect("config sends the selected org header", () =>
}),
)
const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
Effect.provide(live(client)),
)
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(seen).toEqual({
@@ -198,7 +196,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {

View File

@@ -2,10 +2,10 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Option } from "effect"
import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
@@ -19,13 +19,13 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcher service. */
/** Run `body` with a live FileWatcherService. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices(
directory,
FileWatcher.layer,
FileWatcherService.layer,
async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(FileWatcherService.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -55,29 +55,24 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const deferred = yield* Deferred.make<WatcherEvent>()
const cleanup = yield* Effect.sync(() => {
let off = () => {}
off = listen(directory, check, (evt) => {
off()
Deferred.doneUnsafe(deferred, Effect.succeed(evt))
})
return off
return Effect.callback<WatcherEvent>((resume) => {
const cleanup = listen(directory, check, (evt) => {
cleanup()
resume(Effect.succeed(evt))
})
return { cleanup, deferred }
})
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),
({ deferred }) =>
wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })),
(fiber) =>
Effect.gen(function* () {
yield* trigger
return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
return yield* Fiber.join(fiber)
}),
({ cleanup }) => Effect.sync(cleanup),
Fiber.interrupt,
)
}
@@ -88,15 +83,23 @@ function noUpdate<E>(
trigger: Effect.Effect<void, E>,
ms = 500,
) {
return Effect.acquireUseRelease(
wait(directory, check),
({ deferred }) =>
Effect.gen(function* () {
yield* trigger
expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
}),
({ cleanup }) => Effect.sync(cleanup),
)
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) {
@@ -135,7 +138,7 @@ function ready(directory: string) {
// Tests
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
describeWatcher("FileWatcherService", () => {
afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => {

View File

@@ -1,319 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import { testEffect } from "../lib/effect"
import path from "path"
const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer))
const { effect: it } = testEffect(live)
describe("AppFileSystem", () => {
describe("isDir", () => {
it(
"returns true for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isDir(tmp)).toBe(true)
}),
)
it(
"returns false for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
expect(yield* fs.isDir(file)).toBe(false)
}),
)
it(
"returns false for non-existent paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
}),
)
})
describe("isFile", () => {
it(
"returns true for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* fs.writeFileString(file, "hello")
expect(yield* fs.isFile(file)).toBe(true)
}),
)
it(
"returns false for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
expect(yield* fs.isFile(tmp)).toBe(false)
}),
)
})
describe("readJson / writeJson", () => {
it(
"round-trips JSON data",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "data.json")
const data = { name: "test", count: 42, nested: { ok: true } }
yield* fs.writeJson(file, data)
const result = yield* fs.readJson(file)
expect(result).toEqual(data)
}),
)
})
describe("ensureDir", () => {
it(
"creates nested directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const nested = path.join(tmp, "a", "b", "c")
yield* fs.ensureDir(nested)
const info = yield* fs.stat(nested)
expect(info.type).toBe("Directory")
}),
)
it(
"is idempotent",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const dir = path.join(tmp, "existing")
yield* fs.makeDirectory(dir)
yield* fs.ensureDir(dir)
const info = yield* fs.stat(dir)
expect(info.type).toBe("Directory")
}),
)
})
describe("writeWithDirs", () => {
it(
"creates parent directories if missing",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "deep", "nested", "file.txt")
yield* fs.writeWithDirs(file, "hello")
expect(yield* fs.readFileString(file)).toBe("hello")
}),
)
it(
"writes directly when parent exists",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "direct.txt")
yield* fs.writeWithDirs(file, "world")
expect(yield* fs.readFileString(file)).toBe("world")
}),
)
it(
"writes Uint8Array content",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "binary.bin")
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
yield* fs.writeWithDirs(file, content)
const result = yield* fs.readFile(file)
expect(new Uint8Array(result)).toEqual(content)
}),
)
})
describe("findUp", () => {
it(
"finds target in start directory",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
const result = yield* fs.findUp("target.txt", tmp)
expect(result).toEqual([path.join(tmp, "target.txt")])
}),
)
it(
"finds target in parent directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "a", "b")
yield* fs.makeDirectory(child, { recursive: true })
const result = yield* fs.findUp("marker", child, tmp)
expect(result).toEqual([path.join(tmp, "marker")])
}),
)
it(
"returns empty array when not found",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const result = yield* fs.findUp("nonexistent", tmp, tmp)
expect(result).toEqual([])
}),
)
})
describe("up", () => {
it(
"finds multiple targets walking up",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
const child = path.join(tmp, "sub")
yield* fs.makeDirectory(child)
yield* fs.writeFileString(path.join(child, "a.txt"), "a-child")
const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
expect(result).toContain(path.join(child, "a.txt"))
expect(result).toContain(path.join(tmp, "a.txt"))
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
})
describe("glob", () => {
it(
"finds files matching pattern",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
yield* fs.writeFileString(path.join(tmp, "c.json"), "c")
const result = yield* fs.glob("*.ts", { cwd: tmp })
expect(result.sort()).toEqual(["a.ts", "b.ts"])
}),
)
it(
"supports absolute paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
expect(result).toEqual([path.join(tmp, "file.txt")])
}),
)
})
describe("globMatch", () => {
it(
"matches patterns",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
}),
)
})
describe("globUp", () => {
it(
"finds files walking up directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
const child = path.join(tmp, "a", "b")
yield* fs.makeDirectory(child, { recursive: true })
yield* fs.writeFileString(path.join(child, "leaf.md"), "leaf")
const result = yield* fs.globUp("*.md", child, tmp)
expect(result).toContain(path.join(child, "leaf.md"))
expect(result).toContain(path.join(tmp, "root.md"))
}),
)
})
describe("built-in passthrough", () => {
it(
"exists works",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* fs.writeFileString(file, "yes")
expect(yield* fs.exists(file)).toBe(true)
expect(yield* fs.exists(file + ".nope")).toBe(false)
}),
)
it(
"remove works",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const tmp = yield* fs.makeTempDirectoryScoped()
const file = path.join(tmp, "delete-me.txt")
yield* fs.writeFileString(file, "bye")
yield* fs.remove(file)
expect(yield* fs.exists(file)).toBe(false)
}),
)
})
describe("pure helpers", () => {
test("mimeType returns correct types", () => {
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
})
test("contains checks path containment", () => {
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
})
test("overlaps detects overlapping paths", () => {
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
})
})
})

View File

@@ -1,18 +1,17 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
import { Format } from "../../src/format"
import { FormatService } from "../../src/format"
import { Instance } from "../../src/project/instance"
describe("Format", () => {
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, Format.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
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)
@@ -33,8 +32,8 @@ describe("Format", () => {
config: { formatter: false },
})
await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(statuses).toEqual([])
})
})
@@ -48,18 +47,18 @@ describe("Format", () => {
},
})
await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
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("service initializes without error", async () => {
test("init() completes without error", async () => {
await using tmp = await tmpdir()
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use(() => Effect.void))
await withServices(tmp.path, FormatService.layer, async (rt) => {
await rt.runPromise(FormatService.use((s) => s.init()))
})
})
})

View File

@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { spawn } from "../../src/lsp/launch"
import { tmpdir } from "../fixture/fixture"
describe("lsp.launch", () => {
test("spawns cmd scripts with spaces on Windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "with space")
const file = path.join(dir, "echo cmd.cmd")
await fs.mkdir(dir, { recursive: true })
await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")
const proc = spawn(file, ["--stdio"])
expect(await proc.exited).toBe(0)
})
})

View File

@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission"
import { PermissionNext as S } from "../../src/permission"
import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
S.Service.use((svc) =>
S.PermissionEffect.Service.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",

View File

@@ -4,7 +4,6 @@ import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
import { ProviderID } from "../../src/provider/schema"
describe("plugin.auth-override", () => {
test("user plugin overrides built-in github-copilot auth", async () => {
@@ -35,7 +34,7 @@ describe("plugin.auth-override", () => {
directory: tmp.path,
fn: async () => {
const methods = await ProviderAuth.methods()
const copilot = methods[ProviderID.make("github-copilot")]
const copilot = methods["github-copilot"]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")

View File

@@ -2,13 +2,13 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
import { Vcs } from "../../src/project/vcs"
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
@@ -19,31 +19,31 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
function withVcs(
directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
) {
return withServices(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
Layer.merge(FileWatcherService.layer, VcsService.layer),
async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(Vcs.Service.use(() => Effect.void))
await Bun.sleep(500)
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] },
)
}
function withVcsOnly(directory: string, body: (rt: ManagedRuntime.ManagedRuntime<VcsService, never>) => Promise<void>) {
return withServices(directory, VcsService.layer, body)
}
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
function nextBranchUpdate(directory: string, timeout = 10_000) {
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
function nextBranchUpdate(directory: string, timeout = 5000) {
return new Promise<string | undefined>((resolve, reject) => {
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
GlobalBus.off("event", on)
reject(new Error("timed out waiting for BranchUpdated event"))
}, timeout)
@@ -51,8 +51,6 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
function on(evt: BranchEvent) {
if (evt.directory !== directory) return
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
if (settled) return
settled = true
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties.branch)
@@ -73,7 +71,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
@@ -83,7 +81,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(branch).toBeUndefined()
})
})
@@ -116,8 +114,109 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending
const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
const current = await rt.runPromise(VcsService.use((s) => s.branch()))
expect(current).toBe(branch)
})
})
})
describe("Vcs diff", () => {
afterEach(() => Instance.disposeAll())
test("defaultBranch() falls back to main", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
expect(branch).toBe("main")
})
})
test("defaultBranch() uses init.defaultBranch when available", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M trunk`.cwd(tmp.path).quiet()
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch()))
expect(branch).toBe("trunk")
})
})
test("detects current branch from the active worktree", async () => {
await using tmp = await tmpdir({ git: true })
await using wt = await tmpdir()
await $`git branch -M main`.cwd(tmp.path).quiet()
const dir = path.join(wt.path, "feature")
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async (rt) => {
const [branch, base] = await Promise.all([
rt.runPromise(VcsService.use((s) => s.branch())),
rt.runPromise(VcsService.use((s) => s.defaultBranch())),
])
expect(branch).toBe("feature/test")
expect(base).toBe("main")
})
})
test("diff('git') returns uncommitted changes", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "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(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
})
})
test("diff('git') handles tabs in filenames", async () => {
await using tmp = await tmpdir({ git: true })
const file = "tab\tfile.txt"
await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8")
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("git")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file,
status: "added",
}),
]),
)
})
})
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async (rt) => {
const diff = await rt.runPromise(VcsService.use((s) => s.diff("branch")))
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "branch.txt",
status: "added",
}),
]),
)
})
})
})

View File

@@ -2,7 +2,6 @@ import { test, expect, describe } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
@@ -36,8 +35,8 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
})
@@ -61,8 +60,8 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
})
@@ -117,8 +116,8 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
} finally {
@@ -162,8 +161,8 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
},
})
})
@@ -193,8 +192,8 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
)
},
@@ -229,8 +228,8 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
},
})
})
@@ -269,9 +268,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers["amazon-bedrock"]).toBeDefined()
// The model should exist with the us. prefix
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -306,8 +305,8 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -342,8 +341,8 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -378,9 +377,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
expect(providers["amazon-bedrock"]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})

View File

@@ -1,7 +1,6 @@
import { test, expect } from "bun:test"
import path from "path"
import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
@@ -26,8 +25,8 @@ test("GitLab Duo: loads provider with API key from environment", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].key).toBe("test-gitlab-token")
},
})
})
@@ -58,8 +57,8 @@ test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com")
},
})
})
@@ -96,7 +95,7 @@ test("GitLab Duo: loads with OAuth token from auth.json", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers["gitlab"]).toBeDefined()
},
})
})
@@ -131,8 +130,8 @@ test("GitLab Duo: loads with Personal Access Token from auth.json", async () =>
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].key).toBe("glpat-test-pat-token")
},
})
})
@@ -163,8 +162,8 @@ test("GitLab Duo: supports self-hosted instance configuration", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal")
},
})
})
@@ -194,7 +193,7 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers["gitlab"]).toBeDefined()
},
})
})
@@ -217,10 +216,8 @@ test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async ()
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
"context-1m-2025-08-07",
)
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
},
})
})
@@ -253,9 +250,9 @@ test("GitLab Duo: supports feature flags configuration", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.featureFlags).toBeDefined()
expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
},
})
})
@@ -278,8 +275,8 @@ test("GitLab Duo: has multiple agentic chat models available", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.gitlab]).toBeDefined()
const models = Object.keys(providers[ProviderID.gitlab].models)
expect(providers["gitlab"]).toBeDefined()
const models = Object.keys(providers["gitlab"].models)
expect(models.length).toBeGreaterThan(0)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")

View File

@@ -25,11 +25,11 @@ test("provider loaded from env variable", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers["anthropic"]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
expect(providers[ProviderID.anthropic].source).toBe("env")
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
expect(providers["anthropic"].source).toBe("env")
expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
},
})
})
@@ -56,7 +56,7 @@ test("provider loaded from config with apiKey option", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers["anthropic"]).toBeDefined()
},
})
})
@@ -80,7 +80,7 @@ test("disabled_providers excludes provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
expect(providers["anthropic"]).toBeUndefined()
},
})
})
@@ -105,8 +105,8 @@ test("enabled_providers restricts to only listed providers", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers["anthropic"]).toBeDefined()
expect(providers["openai"]).toBeUndefined()
},
})
})
@@ -134,8 +134,8 @@ test("model whitelist filters models for provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
},
@@ -165,8 +165,8 @@ test("model blacklist excludes specific models", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
},
})
@@ -200,9 +200,9 @@ test("custom model alias via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
expect(providers["anthropic"]).toBeDefined()
expect(providers["anthropic"].models["my-alias"]).toBeDefined()
expect(providers["anthropic"].models["my-alias"].name).toBe("My Custom Alias")
},
})
})
@@ -243,9 +243,9 @@ test("custom provider with npm package", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
expect(providers["custom-provider"]).toBeDefined()
expect(providers["custom-provider"].name).toBe("Custom Provider")
expect(providers["custom-provider"].models["custom-model"]).toBeDefined()
},
})
})
@@ -276,10 +276,10 @@ test("env variable takes precedence, config merges options", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers["anthropic"]).toBeDefined()
// Config options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
expect(providers["anthropic"].options.timeout).toBe(60000)
expect(providers["anthropic"].options.chunkTimeout).toBe(15000)
},
})
})
@@ -446,8 +446,8 @@ test("provider with baseURL from config", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
expect(providers["custom-openai"]).toBeDefined()
expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
},
})
})
@@ -484,7 +484,7 @@ test("model cost defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers["test-provider"].models["test-model"]
expect(model.cost.input).toBe(0)
expect(model.cost.output).toBe(0)
expect(model.cost.cache.read).toBe(0)
@@ -522,7 +522,7 @@ test("model options are merged from existing model", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
},
})
@@ -551,7 +551,7 @@ test("provider removed when all models filtered out", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeUndefined()
expect(providers["anthropic"]).toBeUndefined()
},
})
})
@@ -629,7 +629,7 @@ test("getModel uses realIdByKey for aliased models", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
expect(providers["anthropic"].models["my-sonnet"]).toBeDefined()
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
expect(model).toBeDefined()
@@ -673,7 +673,7 @@ test("provider api field sets model api.url", async () => {
fn: async () => {
const providers = await Provider.list()
// api field is stored on model.api.url, used by getSDK to set baseURL
expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1")
},
})
})
@@ -712,7 +712,7 @@ test("explicit baseURL overrides api field", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
},
})
})
@@ -744,7 +744,7 @@ test("model inherits properties from existing database model", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.attachment).toBe(true)
@@ -772,7 +772,7 @@ test("disabled_providers prevents loading even with env var", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers["openai"]).toBeUndefined()
},
})
})
@@ -826,8 +826,8 @@ test("whitelist and blacklist can be combined", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
const models = Object.keys(providers[ProviderID.anthropic].models)
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
@@ -865,7 +865,7 @@ test("model modalities default correctly", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers["test-provider"].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
},
@@ -908,7 +908,7 @@ test("model with custom cost values", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("test-provider")].models["test-model"]
const model = providers["test-provider"].models["test-model"]
expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15)
expect(model.cost.cache.read).toBe(2.5)
@@ -1009,10 +1009,10 @@ test("multiple providers can be configured simultaneously", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers[ProviderID.openai]).toBeDefined()
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.openai].options.timeout).toBe(60000)
expect(providers["anthropic"]).toBeDefined()
expect(providers["openai"]).toBeDefined()
expect(providers["anthropic"].options.timeout).toBe(30000)
expect(providers["openai"].options.timeout).toBe(60000)
},
})
})
@@ -1050,9 +1050,9 @@ test("provider with custom npm package", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("local-llm")]).toBeDefined()
expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
expect(providers["local-llm"]).toBeDefined()
expect(providers["local-llm"].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
},
})
})
@@ -1087,7 +1087,7 @@ test("model alias name defaults to alias key when id differs", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
expect(providers["anthropic"].models["sonnet"].name).toBe("sonnet")
},
})
})
@@ -1127,9 +1127,9 @@ test("provider with multiple env var options only includes apiKey when single en
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
expect(providers["multi-env"]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
expect(providers["multi-env"].key).toBeUndefined()
},
})
})
@@ -1169,9 +1169,9 @@ test("provider with single env var includes apiKey automatically", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("single-env")]).toBeDefined()
expect(providers["single-env"]).toBeDefined()
// Single env option should auto-set key
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
expect(providers["single-env"].key).toBe("my-api-key")
},
})
})
@@ -1206,7 +1206,7 @@ test("model cost overrides existing cost values", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
},
@@ -1253,9 +1253,9 @@ test("completely new provider not in database can be configured", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
expect(providers["brand-new-provider"]).toBeDefined()
expect(providers["brand-new-provider"].name).toBe("Brand New")
const model = providers["brand-new-provider"].models["new-model"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.image).toBe(true)
@@ -1288,11 +1288,11 @@ test("disabled_providers and enabled_providers interaction", async () => {
fn: async () => {
const providers = await Provider.list()
// anthropic: in enabled, not in disabled = allowed
expect(providers[ProviderID.anthropic]).toBeDefined()
expect(providers["anthropic"]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed
expect(providers[ProviderID.openai]).toBeUndefined()
expect(providers["openai"]).toBeUndefined()
// google: not in enabled = NOT allowed (even though not disabled)
expect(providers[ProviderID.google]).toBeUndefined()
expect(providers["google"]).toBeUndefined()
},
})
})
@@ -1327,7 +1327,7 @@ test("model with tool_call false", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
expect(providers["no-tools"].models["basic-model"].capabilities.toolcall).toBe(false)
},
})
})
@@ -1362,7 +1362,7 @@ test("model defaults tool_call to true when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
expect(providers["default-tools"].models["model"].capabilities.toolcall).toBe(true)
},
})
})
@@ -1401,7 +1401,7 @@ test("model headers are preserved", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("headers-provider")].models["model"]
const model = providers["headers-provider"].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
Authorization: "Bearer special-token",
@@ -1445,7 +1445,7 @@ test("provider env fallback - second env var used if first missing", async () =>
fn: async () => {
const providers = await Provider.list()
// Provider should load because fallback env var is set
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
expect(providers["fallback-env"]).toBeDefined()
},
})
})
@@ -1506,7 +1506,7 @@ test("provider name defaults to id when not in database", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
expect(providers["my-custom-id"].name).toBe("my-custom-id")
},
})
})
@@ -1689,7 +1689,7 @@ test("model limit defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("no-limit")].models["model"]
const model = providers["no-limit"].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
},
@@ -1725,10 +1725,10 @@ test("provider options are deeply merged", async () => {
fn: async () => {
const providers = await Provider.list()
// Custom options should be merged
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
expect(providers["anthropic"].options.timeout).toBe(30000)
expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value")
// anthropic custom loader adds its own headers, they should coexist
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
},
})
})
@@ -1762,7 +1762,7 @@ test("custom model inherits npm package from models.dev provider config", async
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.openai].models["my-custom-model"]
const model = providers["openai"].models["my-custom-model"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai")
},
@@ -1797,15 +1797,15 @@ test("custom model inherits api.url from models.dev provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.openrouter]).toBeDefined()
expect(providers["openrouter"]).toBeDefined()
// New model not in database should inherit api.url from provider
const intellect = providers[ProviderID.openrouter].models["prime-intellect/intellect-3"]
const intellect = providers["openrouter"].models["prime-intellect/intellect-3"]
expect(intellect).toBeDefined()
expect(intellect.api.url).toBe("https://openrouter.ai/api/v1")
// Another new model should also inherit api.url
const deepseek = providers[ProviderID.openrouter].models["deepseek/deepseek-r1-0528"]
const deepseek = providers["openrouter"].models["deepseek/deepseek-r1-0528"]
expect(deepseek).toBeDefined()
expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1")
expect(deepseek.name).toBe("DeepSeek R1")
@@ -1832,7 +1832,7 @@ test("model variants are generated for reasoning models", async () => {
fn: async () => {
const providers = await Provider.list()
// Claude sonnet 4 has reasoning capability
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
@@ -1869,7 +1869,7 @@ test("model variants can be disabled via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
@@ -1912,7 +1912,7 @@ test("model variants can be customized via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
},
@@ -1951,7 +1951,7 @@ test("disabled key is stripped from variant config", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
@@ -1989,7 +1989,7 @@ test("all variants can be disabled via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
},
@@ -2027,7 +2027,7 @@ test("variant config merges with generated variants", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@@ -2065,7 +2065,7 @@ test("variants filtered in second pass for database models", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.openai].models["gpt-5"]
const model = providers["openai"].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// Other variants should still exist
@@ -2111,7 +2111,7 @@ test("custom model with variants enabled and disabled", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
const model = providers["custom-reasoning"].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
expect(model.variants!["low"]).toBeDefined()
@@ -2169,8 +2169,8 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
expect(providers["vertex-proxy"]).toBeDefined()
expect(providers["vertex-proxy"].options.baseURL).toBe("https://my-proxy.com/v1")
},
})
})
@@ -2214,7 +2214,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
const model = providers["vertex-openai"].models["gpt-4"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
@@ -2242,7 +2242,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers["cloudflare-ai-gateway"]).toBeDefined()
},
})
})
@@ -2274,8 +2274,8 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
expect(providers["cloudflare-ai-gateway"]).toBeDefined()
expect(providers["cloudflare-ai-gateway"].options.metadata).toEqual({
invoked_by: "test",
project: "opencode",
})

View File

@@ -68,7 +68,6 @@ describe("project.initGit endpoint", () => {
},
})
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
@@ -113,7 +112,6 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}

View File

@@ -4,7 +4,6 @@ import { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Question } from "../../src/question"
const sessionID = SessionID.make("session")
const providerID = ProviderID.make("test")
@@ -916,15 +915,4 @@ describe("session.message-v2.fromError", () => {
},
})
})
test("serializes tagged errors with their message", () => {
const result = MessageV2.fromError(new Question.RejectedError(), { providerID })
expect(result).toStrictEqual({
name: "UnknownError",
data: {
message: "The user dismissed this question",
},
})
})
})

View File

@@ -1,6 +1,6 @@
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 { rm } from "fs/promises"
@@ -48,7 +48,7 @@ afterAll(async () => {
describe("Discovery.pull", () => {
const pull = (url: string) =>
Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL)

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